Files
zyt/admin/src/views/tcm/diagnosis/index_h5.vue
T
2026-05-11 15:04:24 +08:00

2412 lines
77 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>
<div class="dh5">
<!-- 顶部搜索 + 日期/状态 chip 横滚 -->
<header class="dh5-topbar">
<div class="dh5-search">
<el-input
v-model="formData.keyword"
placeholder="患者姓名 / 手机号"
clearable
:prefix-icon="Search"
@keyup.enter="doSearch"
@clear="doSearch"
/>
<el-button class="dh5-search-btn" type="primary" @click="doSearch">查询</el-button>
</div>
<div class="dh5-chips dh5-chips--scroll">
<span
v-for="tab in dateTabs"
:key="tab.value || 'all'"
:class="[
'dh5-chip',
{
active:
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.appointment_date === tab.value
}
]"
@click="handleDateTabClick(tab.value)"
>
{{ tab.label }}
<em v-if="dateCounts[tab.value] !== undefined">{{ dateCounts[tab.value] }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--booking', { active: formData.pending_booking === '1' }]"
@click="handlePendingBookingTabClick"
>
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<span
:class="['dh5-chip', 'dh5-chip--done', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
>
已完成
<em v-if="completedVisitCount !== undefined">{{ completedVisitCount }}</em>
</span>
<span
v-if="hasPermission(['tcm.diagnosis/assign'])"
:class="['dh5-chip', 'dh5-chip--pending', { active: formData.pending_assign === '1' }]"
@click="handlePendingAssignTabClick"
>
待分配医助
<em v-if="pendingAssignCount !== undefined">{{ pendingAssignCount }}</em>
</span>
</div>
<div class="dh5-quick">
<div class="dh5-quick-group">
<span class="dh5-quick-label">挂号</span>
<span
v-for="opt in hasAptOptions"
:key="'a' + opt.value"
:class="['dh5-pill', { active: formData.has_appointment === opt.value }]"
@click="setHasAppointment(opt.value)"
>
{{ opt.label }}
</span>
</div>
<div class="dh5-quick-group">
<span class="dh5-quick-label">确认</span>
<span
v-for="opt in confirmedOptions"
:key="'c' + opt.value"
:class="['dh5-pill', { active: formData.diagnosis_confirmed === opt.value }]"
@click="setConfirmed(opt.value)"
>
{{ opt.label }}
</span>
</div>
<el-button
link
type="primary"
size="small"
class="dh5-more-toggle"
@click="showMoreFilter = !showMoreFilter"
>
{{ showMoreFilter ? '收起' : '更多' }}
<el-icon :class="{ 'rotate-180': showMoreFilter }"><ArrowDown /></el-icon>
</el-button>
</div>
<div v-show="showMoreFilter" class="dh5-more-filter">
<el-select v-model="formData.diagnosis_type" placeholder="诊断类型" clearable size="small" @change="doSearch">
<el-option v-for="item in diagnosisTypeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-select v-model="formData.syndrome_type" placeholder="证型" clearable size="small" @change="doSearch">
<el-option v-for="item in syndromeTypeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
<el-select v-model="formData.assistant_id" placeholder="医助" clearable filterable size="small" @change="doSearch">
<el-option v-for="item in assistantOptions" :key="item.id" :label="item.name" :value="Number(item.id)" />
</el-select>
<el-button size="small" @click="handleReset">重置</el-button>
</div>
</header>
<!-- 卡片列表 -->
<main class="dh5-list" v-loading="pager.loading">
<div v-if="pager.lists.length === 0 && !pager.loading" class="dh5-empty">
<el-empty description="暂无诊单" :image-size="120" />
</div>
<article
v-for="row in pager.lists"
:key="row.id"
:class="['dh5-card', getCardClass(row)]"
>
<!-- 卡片头姓名 / 性别年龄 / 主状态 -->
<header class="dh5-card-head" @click="handleDetail(row.id)">
<div class="dh5-card-avatar">
{{ (row.patient_name || '患').charAt(0) }}
</div>
<div class="dh5-card-id-name">
<div class="dh5-card-name">
{{ row.patient_name || '-' }}
<span v-if="row.gender_desc" class="dh5-meta-gender">{{ row.gender_desc }}</span>
<span v-if="row.age != null" class="dh5-meta-age">{{ row.age }}</span>
</div>
<div class="dh5-card-sub">
#{{ row.id }} · {{ row.diagnosis_date_text || formatDateTime(row.create_time) }}
<span v-if="row.assign_read_at === null && row.assistant_id > 0" class="dh5-tag-new">NEW</span>
</div>
</div>
<div class="dh5-card-tags">
<span v-if="isDiagnosisConfirmed(row)" class="dh5-tag dh5-tag--ok">已确认</span>
<span v-else class="dh5-tag dh5-tag--warn">未确认</span>
<span v-if="row.has_prescription" class="dh5-tag dh5-tag--info">已开方</span>
</div>
</header>
<!-- 卡片体挂号 / 复诊 / 助理 / 类型 -->
<section class="dh5-card-body" @click="handleDetail(row.id)">
<!-- 挂号块 -->
<div :class="['dh5-row', 'dh5-row--apt', appointmentRowClass(row)]">
<span class="dh5-row-key">挂号</span>
<div class="dh5-row-val">
<template v-if="row.has_appointment">
<template v-if="appointmentRows(row).length">
<div
v-for="apt in appointmentRows(row)"
:key="apt.id || `${apt.doctor_id}-${apt.time_text}`"
:class="['dh5-apt-item', appointmentItemClass(apt)]"
>
<span class="dh5-apt-status">{{ appointmentStatusLabelByStatus(apt.status) }}</span>
<span class="dh5-apt-doctor">{{ apt.doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ apt.time_text || '-' }}</span>
</div>
</template>
<template v-else>
<span class="dh5-apt-status">{{ appointmentStatusLabel(row) }}</span>
<span class="dh5-apt-doctor">{{ row.appointment_doctor_name || '-' }}</span>
<span class="dh5-apt-time">{{ row.appointment_time_text || '-' }}</span>
</template>
</template>
<span v-else class="dh5-apt-none">未挂号</span>
</div>
</div>
<!-- 复诊块仅已开方时显示 -->
<div v-if="row.has_prescription" class="dh5-row dh5-row--followup">
<span class="dh5-row-key">复诊</span>
<div class="dh5-row-val">
<span>{{ row.followup_time_text || '—' }}</span>
<span class="dh5-meta-doctor">{{ row.followup_doctor_name || '—' }}</span>
<span v-if="row.followup_rx_voided" class="dh5-tag dh5-tag--gray">处方作废</span>
</div>
</div>
<!-- 助理 / 类型 -->
<div class="dh5-row">
<span class="dh5-row-key">助理</span>
<span class="dh5-row-val">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
</div>
<div v-if="row.diagnosis_type" class="dh5-row">
<span class="dh5-row-key">诊断</span>
<span class="dh5-row-val">
{{ getDictLabel(diagnosisTypeOptions, row.diagnosis_type) }}
<span v-if="row.syndrome_type" class="dh5-meta-mute">·</span>
<span v-if="row.syndrome_type">{{ getDictLabel(syndromeTypeOptions, row.syndrome_type) }}</span>
</span>
</div>
</section>
<!-- 视频旁观受限角色 / 状态时只读说明 -->
<div v-if="watchCallVisibleForRow(row)" class="dh5-card-watch">
<template v-if="canWatchCallEntry(row) && watchCallShowEnterButton(row)">
<el-button type="primary" size="small" :icon="VideoCamera" @click="onWatchCallEntryClick(row)">
进入旁观
</el-button>
<span class="dh5-watch-tip">{{ watchCallEnterTooltip(row) }}</span>
</template>
<template v-else>
<span class="dh5-watch-mute">{{ canWatchCallEntry(row) ? watchCallAssistantIdleText(row) : watchCallPublicStatus(row) }}</span>
</template>
</div>
<!-- 卡片操作3 个常用 + 更多 -->
<footer class="dh5-card-foot">
<el-button
v-if="hasPermission(['tcm.diagnosis/edit'])"
type="primary"
size="small"
plain
@click="handleEdit(row.id)"
>
诊单
</el-button>
<el-button
v-if="hasPermission(['tcm.diagnosis/kaifang'])"
:type="prescriptionActionLabel(row) === '查看' ? 'success' : 'primary'"
size="small"
@click="handlePrescription(row)"
>
{{ prescriptionActionLabel(row) }}
</el-button>
<el-button
v-if="hasPermission(['tcm.diagnosis/guahao'])"
type="success"
size="small"
plain
@click="handleAppointment(row)"
>
预约
</el-button>
<el-button
size="small"
class="dh5-more-btn"
@click="openMoreSheet(row)"
>
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
</footer>
</article>
<div v-if="hasMore" class="dh5-load-more" @click="loadMore">
{{ pager.loading ? '加载中…' : '加载更多' }}
</div>
<div v-if="!hasMore && pager.lists.length > 0" class="dh5-no-more"> 没有更多了 </div>
</main>
<!-- 悬浮新增 -->
<button
v-if="hasPermission(['tcm.diagnosis/add'])"
class="dh5-fab"
type="button"
aria-label="新增诊单"
@click="handleAdd"
>
<el-icon :size="22"><Plus /></el-icon>
</button>
<!-- 复用业务弹窗 -->
<edit-popup ref="editRef" @success="refreshListAfterPopupSave" />
<detail-popup ref="detailRef" />
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
<appointment-popup ref="appointmentRef" @success="refreshListAfterPopupSave" />
<assistant-watch-call-dialog
v-model="watchCallDialogVisible"
:diagnosis-id="watchCallDiagnosisId"
@closed="getLists"
/>
<!-- 行操作 Action Sheet -->
<el-drawer
v-model="moreSheetVisible"
:title="moreSheetRow ? `${moreSheetRow.patient_name || '诊单'} · 更多操作` : '更多操作'"
direction="btt"
size="auto"
:with-header="true"
:destroy-on-close="true"
>
<div class="dh5-actions" v-if="moreSheetRow">
<button
v-if="hasPermission(['tcm.diagnosis/assign'])"
class="dh5-action"
@click="runAction('assign')"
>
<el-icon><User /></el-icon>
<span>指派医助</span>
</button>
<button
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/videoQr'])"
class="dh5-action"
@click="runAction('videoQr')"
>
<el-icon><Picture /></el-icon>
<span>视频二维码</span>
</button>
<button
v-if="isAppointmentActiveForVideo(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
class="dh5-action"
@click="runAction('confirmQr')"
>
<el-icon><Picture /></el-icon>
<span>诊单二维码</span>
</button>
<button
v-if="canCancelAppointmentRow(moreSheetRow) && hasPermission(['tcm.diagnosis/guahao'])"
class="dh5-action"
@click="runAction('cancelApt')"
>
<el-icon><CircleClose /></el-icon>
<span>取消挂号</span>
</button>
<button
v-if="hasPermission(['tcm.diagnosis/edit'])"
class="dh5-action"
@click="runAction('fillIdCard')"
>
<el-icon><Postcard /></el-icon>
<span>补全身份证</span>
</button>
<button
v-if="hasPermission(['tcm.diagnosis/order'])"
class="dh5-action"
@click="runAction('order')"
>
<el-icon><Document /></el-icon>
<span>创建订单</span>
</button>
<button class="dh5-action" @click="runAction('wechat')">
<el-icon><ChatDotRound /></el-icon>
<span>企微记录</span>
</button>
<button
v-if="hasPermission(['tcm.diagnosis/delete'])"
class="dh5-action dh5-action--danger"
@click="runAction('delete')"
>
<el-icon><Delete /></el-icon>
<span>删除诊单</span>
</button>
</div>
</el-drawer>
<!-- 指派医助 Drawer -->
<el-drawer v-model="assignDialogVisible" title="指派医助" direction="btt" size="46%">
<el-form :model="assignForm" label-position="top" class="dh5-form">
<el-form-item label="选择医助" required>
<el-select
v-model="assignForm.assistant_id"
placeholder="请选择医助"
class="!w-full"
filterable
clearable
>
<el-option
v-for="item in assistantOptions"
:key="item.id"
:label="item.name"
:value="Number(item.id)"
/>
</el-select>
</el-form-item>
<el-form-item label="诊单信息">
<div class="dh5-form-tip">
<div>患者{{ currentRow?.patient_name }}</div>
<div>诊断日期{{ currentRow?.diagnosis_date_text || '—' }}</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button class="dh5-flex1" @click="assignDialogVisible = false">取消</el-button>
<el-button class="dh5-flex1" type="primary" :loading="assignLoading" @click="handleConfirmAssign">
确定指派
</el-button>
</template>
</el-drawer>
<!-- 创建订单 Drawer -->
<el-drawer v-model="createOrderVisible" title="创建订单" direction="btt" size="64%" @close="resetOrderForm">
<el-form
ref="orderFormRef"
:model="orderForm"
:rules="orderRules"
label-position="top"
class="dh5-form"
>
<el-form-item label="患者">
<span>{{ orderForm.patient_name }} ({{ maskPhone(orderForm.phone) }})</span>
</el-form-item>
<el-form-item label="订单类型" prop="order_type">
<el-select v-model="orderForm.order_type" placeholder="请选择" class="!w-full">
<el-option label="挂号费" :value="1" />
<el-option label="问诊费" :value="2" />
<el-option label="药品费用" :value="3" />
<el-option label="首付费用" :value="4" />
<el-option label="尾款费用" :value="5" />
<el-option label="其他费用" :value="6" />
<el-option label="全部费用" :value="7" />
<el-option label="驼奶费用" :value="8" />
</el-select>
</el-form-item>
<el-form-item label="订单金额" prop="amount">
<el-input-number
v-model="orderForm.amount"
:min="0"
:step="0.01"
:precision="2"
placeholder="请输入金额"
class="!w-full"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="orderForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button class="dh5-flex1" @click="createOrderVisible = false">取消</el-button>
<el-button class="dh5-flex1" type="primary" :loading="orderLoading" @click="submitOrder">
创建订单
</el-button>
</template>
</el-drawer>
<!-- 补全身份证 Dialog -->
<el-dialog
v-model="fillIdCardVisible"
title="补全身份证号"
:close-on-click-modal="false"
@close="fillIdCardForm.id_card = ''"
>
<el-form
ref="fillIdCardFormRef"
:model="fillIdCardForm"
:rules="fillIdCardRules"
label-position="top"
class="dh5-form"
>
<el-form-item label="患者">
<span class="font-medium">{{ fillIdCardForm.patient_name }}</span>
</el-form-item>
<el-form-item label="身份证号" prop="id_card">
<el-input
v-model="fillIdCardForm.id_card"
placeholder="请输入15或18位身份证号"
maxlength="18"
show-word-limit
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fillIdCardVisible = false">取消</el-button>
<el-button type="primary" :loading="fillIdCardLoading" @click="submitFillIdCard">
提交
</el-button>
</template>
</el-dialog>
<!-- 二维码 Dialog -->
<el-dialog
v-model="qrcodeDialogVisible"
:title="qrcodeDialogTitle"
:close-on-click-modal="false"
>
<div class="dh5-qr">
<div v-if="qrcodeLoading" class="dh5-qr-loading">
<el-icon class="is-loading" :size="36"><Loading /></el-icon>
<div class="dh5-qr-tip">生成中...</div>
</div>
<div v-else-if="qrcodeUrl">
<img :src="qrcodeUrl" alt="小程序二维码" class="dh5-qr-img" />
<div class="dh5-qr-meta">
<div>患者{{ currentQRCodePatient?.patient_name }}</div>
<div>挂号时间{{ qrcodeAppointmentTimeText }}</div>
<div class="dh5-qr-hint">请使用企业微信扫描二维码然后转发给患者</div>
</div>
</div>
<div v-else class="dh5-qr-fail">生成失败请重试</div>
</div>
<template #footer>
<el-button @click="qrcodeDialogVisible = false">关闭</el-button>
<el-button v-if="!qrcodeLoading" type="primary" @click="handleRegenerateQRCode">重新生成</el-button>
</template>
</el-dialog>
<!-- 企微记录 Drawer -->
<el-drawer
v-model="wechatDrawerVisible"
:title="`企微记录 - ${wechatCurrentPatient?.patient_name || ''}`"
direction="rtl"
size="100%"
:destroy-on-close="true"
>
<div v-if="wechatContactInfo" class="dh5-wx-contact">
<div class="dh5-wx-contact-row">
<span class="dh5-wx-key">手机号</span>
<span class="dh5-wx-val">{{ maskPhone(wechatContactInfo.phone) || '-' }}</span>
</div>
<div class="dh5-wx-contact-row">
<span class="dh5-wx-key">企微 ID</span>
<span class="dh5-wx-val">
<span v-if="wechatContactInfo.external_userid">{{ wechatContactInfo.external_userid }}</span>
<el-tag v-else type="info" size="small">未关联</el-tag>
</span>
</div>
<div class="dh5-wx-contact-row">
<span class="dh5-wx-key">会话存档</span>
<span class="dh5-wx-val">
<el-tag v-if="wechatContactInfo.msgaudit_enabled" type="success" size="small">已开通</el-tag>
<el-tag v-else type="info" size="small">未配置</el-tag>
</span>
</div>
</div>
<div class="dh5-wx-add">
<el-input
v-model="wechatNewNote"
type="textarea"
:rows="2"
placeholder="添加沟通记录..."
/>
<el-button
type="primary"
:loading="wechatAddLoading"
size="small"
@click="handleAddRecord"
>
添加
</el-button>
</div>
<div v-loading="wechatRecordsLoading" class="dh5-wx-list-wrap">
<div v-if="wechatRecords.length === 0 && !wechatRecordsLoading" class="dh5-wx-empty">
暂无聊天记录
</div>
<div v-else class="dh5-wx-list">
<div v-for="record in wechatRecords" :key="record.id" class="dh5-wx-item">
<div class="dh5-wx-head">
<div class="dh5-wx-head-left">
<el-tag :type="record.direction === 0 ? 'primary' : 'success'" size="small">
{{ record.direction === 0 ? '员工' : '客户' }}
</el-tag>
<span class="dh5-wx-name">
{{ record.direction === 0 ? record.staff_name : record.external_name || wechatCurrentPatient?.patient_name }}
</span>
<el-tag v-if="record.msg_type === 'note'" type="warning" size="small">备注</el-tag>
</div>
<div class="dh5-wx-head-right">
<span class="dh5-wx-time">{{ formatTimestamp(record.chat_time) }}</span>
<el-button type="danger" link size="small" @click="handleDeleteRecord(record.id)">
删除
</el-button>
</div>
</div>
<div class="dh5-wx-content">{{ record.content }}</div>
<div v-if="record.media_url" class="dh5-wx-media">
<el-image
:src="record.media_url"
style="max-width: 200px"
fit="contain"
:preview-src-list="[record.media_url]"
/>
</div>
</div>
</div>
<div v-if="wechatRecordTotal > wechatRecords.length" class="dh5-load-more" @click="loadMoreRecords">
加载更多
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts" name="tcmDiagnosisH5">
import {
tcmDiagnosisLists,
tcmDiagnosisDelete,
tcmDiagnosisAssign,
getAssistants,
generateMiniProgramQrcode,
generateOrderQrcode,
getWechatChatRecords,
addWechatChatRecord,
deleteWechatChatRecord,
getWechatExternalContact,
fillIdCard
} from '@/api/tcm'
import { orderCreate } from '@/api/order'
import { cancelAppointment } from '@/api/doctor'
import { getDictData } from '@/api/app'
import { getWeappConfig } from '@/api/channel/weapp'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
import { hasPermission } from '@/utils/perm'
import {
Loading,
Plus,
Search,
ArrowDown,
Picture,
User,
Document,
Delete,
CircleClose,
Postcard,
ChatDotRound,
VideoCamera
} from '@element-plus/icons-vue'
import useUserStore from '@/stores/modules/user'
import { defineAsyncComponent, computed, reactive, ref, onMounted, onUnmounted } from 'vue'
import dayjs from 'dayjs'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
const TcmPrescription = defineAsyncComponent(() => import('@/components/tcm-prescription/index.vue'))
const AppointmentPopup = defineAsyncComponent(() => import('./appointment.vue'))
const AssistantWatchCallDialog = defineAsyncComponent(() => import('./components/AssistantWatchCallDialog.vue'))
const userStore = useUserStore()
// ========== 工具 ==========
const formatDateTime = (timestamp: number | string) => {
if (!timestamp) return '-'
const ts = String(timestamp).length === 10 ? Number(timestamp) * 1000 : Number(timestamp)
return dayjs(ts).format('YYYY-MM-DD HH:mm')
}
const formatTimestamp = (ts: number) => {
if (!ts) return '-'
const t = String(ts).length === 10 ? ts * 1000 : ts
return dayjs(t).format('YYYY-MM-DD HH:mm')
}
const maskPhone = (phone: string) => {
if (!phone || phone.length < 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
const pad = (n: number) => String(n).padStart(2, '0')
const yesterdayStr = computed(() => {
const t = new Date(); t.setDate(t.getDate() - 1)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const dayBeforeStr = computed(() => {
const t = new Date(); t.setDate(t.getDate() - 2)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const todayStr = computed(() => {
const t = new Date()
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const tomorrowStr = computed(() => {
const t = new Date(); t.setDate(t.getDate() + 1)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
const dayAfterStr = computed(() => {
const t = new Date(); t.setDate(t.getDate() + 2)
return `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`
})
// ========== 表单 / 列表 ==========
const formData = reactive({
keyword: '',
diagnosis_type: '',
syndrome_type: '',
assistant_id: '',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
pending_assign: '' as '' | '1',
pending_booking: '' as '' | '1',
completed_appointment: '' as '' | '1'
})
const showMoreFilter = ref(false)
const hasAptOptions = [
{ label: '全部', value: '' },
{ label: '已挂号', value: '1' },
{ label: '未挂号', value: '0' }
]
const confirmedOptions = [
{ label: '全部', value: '' },
{ label: '已确认', value: '1' },
{ label: '未确认', value: '0' }
]
const dateTabs = computed(() => [
{ label: '昨天挂号', value: yesterdayStr.value },
{ label: '前天挂号', value: dayBeforeStr.value },
{ label: '当天挂号', value: todayStr.value },
{ label: '明天挂号', value: tomorrowStr.value },
{ label: '后天挂号', value: dayAfterStr.value },
{ label: '全部', value: '' }
])
const dateCounts = ref<Record<string, number>>({})
const pendingBookingCount = ref<number | undefined>(undefined)
const completedVisitCount = ref<number | undefined>(undefined)
const pendingAssignCount = ref<number | undefined>(undefined)
const { pager, getLists } = usePaging({
fetchFun: tcmDiagnosisLists,
params: formData
})
const hasMore = computed(() => pager.lists.length < (pager.count || 0))
const refreshListAfterPopupSave = () => getLists({ silent: true })
const doSearch = () => {
pager.page = 1
getLists()
fetchDateCounts()
}
const handleReset = () => {
formData.keyword = ''
formData.diagnosis_type = ''
formData.syndrome_type = ''
formData.assistant_id = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = todayStr.value
formData.has_appointment = ''
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
const setHasAppointment = (v: string) => {
formData.has_appointment = v as '' | '0' | '1'
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
const setConfirmed = (v: string) => {
formData.diagnosis_confirmed = v as '' | '0' | '1'
formData.pending_booking = ''
formData.completed_appointment = ''
pager.page = 1
getLists()
fetchDateCounts()
}
const handleDateTabClick = async (value: string) => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = value
pager.page = 1
await getLists()
fetchDateCounts()
}
const handlePendingBookingTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = '1'
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
pager.page = 1
await getLists()
fetchDateCounts()
}
const handleCompletedVisitTabClick = async () => {
formData.pending_assign = ''
formData.pending_booking = ''
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
const handlePendingAssignTabClick = async () => {
formData.pending_assign = '1'
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
tcmDiagnosisLists({ appointment_date: yesterdayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayBeforeStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: todayStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: tomorrowStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ appointment_date: dayAfterStr.value, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ has_appointment: 0, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ completed_appointment: 1, page_no: 1, page_size: 1 }),
tcmDiagnosisLists({ pending_assign: 1, page_no: 1, page_size: 1 })
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
[dayBeforeStr.value]: dayBefore?.count ?? 0,
[todayStr.value]: today?.count ?? 0,
[tomorrowStr.value]: tomorrow?.count ?? 0,
[dayAfterStr.value]: dayAfter?.count ?? 0,
'': all?.count ?? 0
}
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
}
const loadMore = async () => {
if (pager.loading || !hasMore.value) return
const next = (pager.page || 1) + 1
pager.loading = true
try {
const res = await tcmDiagnosisLists({
page_no: next,
page_size: pager.size,
...formData
})
pager.page = next
pager.lists.push(...(res?.lists || []))
pager.count = res?.count ?? pager.count
} catch (e) {
console.error('加载更多失败:', e)
} finally {
pager.loading = false
}
}
// ========== 字典 ==========
const diagnosisTypeOptions = ref<any[]>([])
const syndromeTypeOptions = ref<any[]>([])
const assistantOptions = ref<any[]>([])
const getDictOptions = async () => {
try {
const [diagnosisType, syndromeType, assistants] = await Promise.all([
getDictData({ type: 'diagnosis_type' }),
getDictData({ type: 'syndrome_type' }),
getAssistants()
])
diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || []
syndromeTypeOptions.value = syndromeType?.syndrome_type || []
assistantOptions.value = assistants || []
} catch (e) {
console.error('获取字典数据失败:', e)
}
}
const getDictLabel = (options: any[], value: string) => {
const item = options.find(opt => opt.value === value)
return item ? item.name : value || '-'
}
const getAssistantName = (assistantId: number | string | null | undefined) => {
if (!assistantId && assistantId !== 0) return '-'
const id = typeof assistantId === 'string' ? parseInt(assistantId) : (assistantId as number)
if (assistantOptions.value.length === 0) return '...'
const a = assistantOptions.value.find(item => {
const itemId = typeof item.id === 'string' ? parseInt(item.id) : item.id
return itemId === id
})
return a ? a.name : '-'
}
// ========== 列卡片状态 ==========
const isDiagnosisConfirmed = (row: any) => {
const records = row.DiagnosisViewRecord || []
return records.length > 0 && records.some((r: any) => r.is_confirmed == 1)
}
const getCardClass = (row: any) => {
if (!isDiagnosisConfirmed(row)) return 'dh5-card--unconfirmed'
if (!row.has_appointment) return 'dh5-card--noapt'
return ''
}
const appointmentStatusLabel = (row: any) => {
const s = Number(row.appointment_status)
if (s === 3) return '已完成'
if (s === 4) return '已过号'
return '已挂号'
}
const appointmentStatusLabelByStatus = (status: number | string) => {
const s = Number(status)
if (s === 3) return '已完成'
if (s === 4) return '已过号'
return '已挂号'
}
const appointmentRows = (row: any) => {
const list = Array.isArray(row.appointments) ? row.appointments : []
if (list.length > 0) return list
if (!row.has_appointment) return []
return [{
id: row.appointment_id,
status: row.appointment_status,
doctor_id: row.appointment_doctor_id,
doctor_name: row.appointment_doctor_name,
time_text: row.appointment_time_text
}]
}
const appointmentItemClass = (apt: any) => {
const s = Number(apt?.status)
return {
'dh5-apt-item--done': s === 3,
'dh5-apt-item--missed': s === 4,
'dh5-apt-item--active': s === 1
}
}
const appointmentRowClass = (row: any) => {
const s = Number(row.appointment_status)
return {
'has-apt': row.has_appointment,
'apt-row-done': row.has_appointment && s === 3,
'apt-row-missed': row.has_appointment && s === 4
}
}
const isAppointmentActiveForVideo = (row: any) =>
row.has_appointment && Number(row.appointment_status) === 1
const canCancelAppointmentRow = (row: any) => {
const s = Number(row.appointment_status)
return !!(row.has_appointment && row.appointment_id && (s === 1 || s === 4))
}
// 视频旁观(与 PC 端一致)
type VideoCallHint = { state: string; label: string; start_time?: number; end_time?: number }
const isAssignedAssistant = (row: any) => {
const aid = row.assistant_id ?? row.assistant
if (aid === null || aid === undefined || aid === '') return false
return Number(aid) === Number(userStore.userInfo?.id)
}
const canWatchCallEntry = (row: any) =>
isAssignedAssistant(row) && hasPermission(['tcm.diagnosis/watchCall'])
const watchCallState = (row: any) => (row.video_call_hint?.state as string) || 'none'
const watchCallShowEnterButton = (row: any) => {
const st = watchCallState(row)
return st === 'live' || st === 'pending_room'
}
const watchCallEnterTooltip = (row: any) => {
if (watchCallState(row) === 'pending_room') {
return '医生未接通或未同步房间'
}
return '点击进入实时房间'
}
const watchCallAssistantIdleText = (row: any) => {
const h = row.video_call_hint as VideoCallHint | undefined
if (h?.label) return h.label
return '暂无可旁观通话'
}
const watchCallPublicStatus = (row: any) => {
const st = watchCallState(row)
if (st === 'none') return ''
if (st === 'live') return '通话中'
if (st === 'pending_room') return '接通中'
const h = row.video_call_hint as VideoCallHint | undefined
return h?.label || ''
}
/** 是否需要在卡片显示视频旁观条 */
const watchCallVisibleForRow = (row: any) => {
if (canWatchCallEntry(row)) return true
const st = watchCallState(row)
return st === 'live' || st === 'pending_room'
}
const watchCallDialogVisible = ref(false)
const watchCallDiagnosisId = ref(0)
const onWatchCallEntryClick = (row: any) => {
if (watchCallState(row) !== 'live') {
feedback.msgWarning('医生未接通或未同步房间号,请稍后再试')
return
}
watchCallDiagnosisId.value = Number(row.id) || 0
watchCallDialogVisible.value = true
}
// ========== 主操作 ==========
const editRef = ref()
const detailRef = ref()
const prescriptionRef = ref()
const appointmentRef = ref()
const handleAdd = () => editRef.value?.open('add')
const handleEdit = (id: number) => editRef.value?.open('edit', id)
const handleDetail = (id: number) => detailRef.value?.open(id)
const prescriptionActionLabel = (row: any) => {
if (
row?.prescription_audit_status === 1 &&
Number(row?.prescription_void_status) !== 1
) {
return '查看'
}
return '开方'
}
const handlePrescription = (row: any) => {
if (!row?.id) {
feedback.msgWarning('诊单信息不完整')
return
}
prescriptionRef.value?.open({
...row,
diagnosis_id: row.id,
id: row.appointment_id || 0
})
}
const onPrescriptionSuccess = () => {
getLists()
;(editRef.value as { refreshCaseList?: () => void } | undefined)?.refreshCaseList?.()
}
const handleAppointment = (row: any) => {
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
appointmentRef.value?.open(row)
}
const handleDelete = async (id: number) => {
await feedback.confirm('确定要删除该诊单吗?')
await tcmDiagnosisDelete({ id })
getLists()
}
// ========== Action Sheet ==========
const moreSheetVisible = ref(false)
const moreSheetRow = ref<any>(null)
const openMoreSheet = (row: any) => {
moreSheetRow.value = row
moreSheetVisible.value = true
}
const runAction = (cmd: string) => {
const row = moreSheetRow.value
moreSheetVisible.value = false
if (!row) return
switch (cmd) {
case 'assign': handleSingleAssign(row); break
case 'videoQr':
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码')
return
}
handleVideoQRCode(row); break
case 'confirmQr': handleMiniProgramQRCode(row); break
case 'cancelApt': handleCancelAppointment(row); break
case 'fillIdCard': handleFillIdCard(row); break
case 'order': handleCreateOrder(row); break
case 'wechat': handleWechatRecords(row); break
case 'delete': handleDelete(row.id); break
}
}
// ========== 指派 ==========
const assignDialogVisible = ref(false)
const assignLoading = ref(false)
const currentRow = ref<any>(null)
const assignForm = ref({ assistant_id: null as number | null })
const handleSingleAssign = (row: any) => {
currentRow.value = row
assignForm.value.assistant_id = row.assistant_id ? Number(row.assistant_id) : null
assignDialogVisible.value = true
}
const handleConfirmAssign = async () => {
if (!assignForm.value.assistant_id) { feedback.msgWarning('请选择医助'); return }
assignLoading.value = true
try {
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id
})
assignDialogVisible.value = false
getLists()
} catch (e) {
console.error('指派失败:', e)
} finally {
assignLoading.value = false
}
}
// ========== 创建订单 ==========
const createOrderVisible = ref(false)
const orderFormRef = ref()
const orderLoading = ref(false)
const orderForm = reactive({
patient_id: '' as string | number,
patient_name: '',
phone: '',
order_type: '' as string | number,
amount: 0,
remark: '',
has_appointment: false,
appointment_time_text: ''
})
const orderRules = {
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
amount: [{ required: true, message: '请输入订单金额', trigger: 'blur' }]
}
const handleCreateOrder = (row: any) => {
orderForm.patient_id = row.patient_id || row.id
orderForm.patient_name = row.patient_name
orderForm.phone = row.phone || ''
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
orderForm.has_appointment = !!row.has_appointment
orderForm.appointment_time_text = row.appointment_time_text || ''
orderFormRef.value?.clearValidate()
createOrderVisible.value = true
}
const resetOrderForm = () => {
orderForm.patient_id = ''
orderForm.patient_name = ''
orderForm.phone = ''
orderForm.order_type = ''
orderForm.amount = 0
orderForm.remark = ''
orderForm.has_appointment = false
orderForm.appointment_time_text = ''
orderFormRef.value?.clearValidate()
}
const submitOrder = async () => {
try {
await orderFormRef.value?.validate()
orderLoading.value = true
const res = await orderCreate({
patient_id: orderForm.patient_id,
order_type: orderForm.order_type,
amount: orderForm.amount,
remark: orderForm.remark
})
createOrderVisible.value = false
getLists()
const orderNo = res?.order_no
if (orderNo) {
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
qrcodeDialogTitle.value = '收款二维码'
currentQRCodePatient.value = {
patient_name: orderForm.patient_name,
has_appointment: orderForm.has_appointment,
appointment_time_text: orderForm.appointment_time_text
}
try {
const qrRes = await generateOrderQrcode({ order_no: orderNo })
if (qrRes?.qrcode_url) qrcodeUrl.value = qrRes.qrcode_url
else feedback.msgError('二维码生成失败')
} catch (e: any) {
feedback.msgError(e?.msg || '生成二维码失败')
} finally {
qrcodeLoading.value = false
}
}
} catch (e) {
console.error('创建订单失败:', e)
} finally {
orderLoading.value = false
}
}
// ========== 补全身份证 ==========
const fillIdCardVisible = ref(false)
const fillIdCardLoading = ref(false)
const fillIdCardFormRef = ref()
const fillIdCardForm = reactive({ id: 0, patient_name: '', id_card: '' })
const validateIdCard = (_rule: any, value: string, callback: (e?: Error) => void) => {
if (!value) { callback(new Error('请输入身份证号')); return }
const valid18 = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)
const valid15 = /^[1-9]\d{5}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}$/.test(value)
if (valid18 || valid15) callback()
else callback(new Error('请输入15或18位有效身份证号'))
}
const fillIdCardRules = {
id_card: [{ required: true, validator: validateIdCard, trigger: 'blur' }]
}
const handleFillIdCard = (row: any) => {
fillIdCardForm.id = row.id
fillIdCardForm.patient_name = row.patient_name || '-'
fillIdCardForm.id_card = row.id_card || ''
fillIdCardVisible.value = true
}
const submitFillIdCard = async () => {
await fillIdCardFormRef.value?.validate()
fillIdCardLoading.value = true
try {
await fillIdCard({ id: fillIdCardForm.id, id_card: fillIdCardForm.id_card })
feedback.msgSuccess('补全成功,年龄已自动更新')
fillIdCardVisible.value = false
getLists()
} catch (e: any) {
feedback.msgError(e?.msg || '补全失败')
} finally {
fillIdCardLoading.value = false
}
}
// ========== 二维码 ==========
const qrcodeDialogVisible = ref(false)
const qrcodeLoading = ref(false)
const qrcodeUrl = ref('')
const currentQRCodePatient = ref<any>(null)
const qrcodeDialogTitle = ref('小程序二维码')
const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
const qrcodeAppointmentTimeText = computed(() => {
const p = currentQRCodePatient.value
if (!p) return '—'
if (!p.has_appointment) return '未挂号'
return p.appointment_time_text || '—'
})
const handleVideoQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可生成视频二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'video'
currentQRCodePatient.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
qrcodeDialogTitle.value = '视频二维码'
try {
const config = await getWeappConfig()
if (!config?.app_id) {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
doctor_id: row.appointment_doctor_id,
diagnosis_id: row.appointment_doctor_id,
patient_id: row.patient_id,
share_user_id: userStore.userInfo?.id || '',
mini_program_path: 'pages/login/login'
})
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
else feedback.msgError('二维码生成失败')
} catch (e: any) {
feedback.msgError(e?.msg || '生成二维码失败')
} finally {
qrcodeLoading.value = false
}
}
const handleMiniProgramQRCode = async (row: any) => {
if (!isAppointmentActiveForVideo(row)) {
feedback.msgWarning('仅「已预约」状态可使用诊单二维码'); return
}
if (!row.patient_id) { feedback.msgWarning('患者信息不完整'); return }
lastQRCodeType.value = 'confirm'
currentQRCodePatient.value = row
qrcodeDialogVisible.value = true
qrcodeLoading.value = true
qrcodeUrl.value = ''
qrcodeDialogTitle.value = '诊单二维码'
try {
const config = await getWeappConfig()
if (!config?.app_id) {
feedback.msgError('小程序未配置')
qrcodeDialogVisible.value = false
return
}
const result = await generateMiniProgramQrcode({
diagnosis_id: row.id,
doctor_id: row.appointment_doctor_id,
patient_id: row.patient_id,
share_user_id: userStore.userInfo?.id || ''
})
if (result?.qrcode_url) qrcodeUrl.value = result.qrcode_url
else feedback.msgError('二维码生成失败')
} catch (e: any) {
feedback.msgError(e?.msg || '生成二维码失败')
} finally {
qrcodeLoading.value = false
}
}
const handleRegenerateQRCode = () => {
if (currentQRCodePatient.value) {
lastQRCodeType.value === 'video'
? handleVideoQRCode(currentQRCodePatient.value)
: handleMiniProgramQRCode(currentQRCodePatient.value)
}
}
// ========== 取消挂号 ==========
const handleCancelAppointment = async (row: any) => {
if (!canCancelAppointmentRow(row)) {
const s = Number(row.appointment_status)
if (row.has_appointment && s === 3) feedback.msgWarning('已完成就诊,无法取消挂号')
else feedback.msgWarning('该诊单未挂号或挂号信息异常')
return
}
try {
await feedback.confirm(`确定要取消患者「${row.patient_name}」的挂号吗?`)
await cancelAppointment({ id: row.appointment_id })
feedback.msgSuccess('取消挂号成功')
getLists()
fetchDateCounts()
} catch (e: any) {
if (e !== 'cancel') feedback.msgError(e?.msg || '取消挂号失败')
}
}
// ========== 企业微信 ==========
const wechatDrawerVisible = ref(false)
const wechatCurrentPatient = ref<any>(null)
const wechatRecords = ref<any[]>([])
const wechatRecordTotal = ref(0)
const wechatRecordsLoading = ref(false)
const wechatContactInfo = ref<any>(null)
const wechatNewNote = ref('')
const wechatAddLoading = ref(false)
const wechatPage = ref(1)
const handleWechatRecords = async (row: any) => {
wechatCurrentPatient.value = row
wechatDrawerVisible.value = true
wechatRecords.value = []
wechatRecordTotal.value = 0
wechatContactInfo.value = null
wechatNewNote.value = ''
wechatPage.value = 1
wechatRecordsLoading.value = true
try {
const [recordsRes, contactRes] = await Promise.all([
getWechatChatRecords({ diagnosis_id: row.id, patient_id: row.patient_id, page_no: 1, page_size: 20 }),
getWechatExternalContact({ patient_id: row.patient_id || row.id }).catch(() => null)
])
wechatRecords.value = recordsRes?.lists || []
wechatRecordTotal.value = recordsRes?.count || 0
wechatContactInfo.value = contactRes
} catch (e) {
console.error(e)
} finally {
wechatRecordsLoading.value = false
}
}
const loadMoreRecords = async () => {
if (!wechatCurrentPatient.value) return
wechatPage.value++
try {
const res = await getWechatChatRecords({
diagnosis_id: wechatCurrentPatient.value.id,
patient_id: wechatCurrentPatient.value.patient_id,
page_no: wechatPage.value, page_size: 20
})
wechatRecords.value.push(...(res?.lists || []))
} catch (e) { console.error(e) }
}
const handleAddRecord = async () => {
if (!wechatNewNote.value.trim()) { feedback.msgWarning('请输入内容'); return }
wechatAddLoading.value = true
try {
await addWechatChatRecord({
diagnosis_id: wechatCurrentPatient.value.id,
patient_id: wechatCurrentPatient.value.patient_id,
content: wechatNewNote.value.trim(),
msg_type: 'note',
staff_name: userStore.userInfo?.name || '',
external_name: wechatCurrentPatient.value.patient_name || ''
})
wechatNewNote.value = ''
handleWechatRecords(wechatCurrentPatient.value)
} catch (e: any) {
feedback.msgError(e?.msg || '添加失败')
} finally {
wechatAddLoading.value = false
}
}
const handleDeleteRecord = async (id: number) => {
await feedback.confirm('确定删除该记录吗?')
try {
await deleteWechatChatRecord({ id })
wechatRecords.value = wechatRecords.value.filter(r => r.id !== id)
wechatRecordTotal.value--
} catch (e: any) {
feedback.msgError(e?.msg || '删除失败')
}
}
// ========== 生命周期 ==========
const SILENT_LIST_POLL_MS = 15_000
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
const silentRefresh = () => {
if (typeof document !== 'undefined' && document.hidden) return
void getLists({ silent: true }).catch(() => {})
void fetchDateCounts()
}
onMounted(async () => {
document.body.classList.add('h5-mode')
formData.appointment_date = todayStr.value
getDictOptions()
getLists()
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => fetchDateCounts(), { timeout: 2500 })
} else {
setTimeout(fetchDateCounts, 200)
}
silentListPollTimer = setInterval(silentRefresh, SILENT_LIST_POLL_MS)
})
onUnmounted(() => {
document.body.classList.remove('h5-mode')
if (silentListPollTimer) {
clearInterval(silentListPollTimer)
silentListPollTimer = null
}
})
</script>
<style lang="scss" scoped>
/* ============= 设计变量(手机端紧凑) ============= */
$dh5-primary: #2563eb;
$dh5-primary-soft: #eff6ff;
$dh5-success: #10b981;
$dh5-warn: #f59e0b;
$dh5-danger: #ef4444;
$dh5-text: #0f172a;
$dh5-text-mute: #475569;
$dh5-line: #e2e8f0;
$dh5-bg: #f4f6fb;
$dh5-card-bg: #ffffff;
.dh5 {
max-width: 560px;
margin: 0 auto;
min-height: 100vh;
background: $dh5-bg;
padding-bottom: 88px;
color: $dh5-text;
-webkit-font-smoothing: antialiased;
}
/* ============= 顶部 ============= */
.dh5-topbar {
background: $dh5-card-bg;
padding: 12px 14px 10px;
position: sticky;
top: 0;
z-index: 20;
box-shadow: 0 1px 0 $dh5-line;
}
.dh5-search {
display: flex;
gap: 8px;
align-items: center;
:deep(.el-input__wrapper) {
border-radius: 10px;
background: $dh5-bg;
box-shadow: none !important;
padding: 4px 10px;
}
.dh5-search-btn {
border-radius: 10px;
height: 36px;
}
}
.dh5-chips {
display: flex;
gap: 6px;
margin-top: 10px;
}
.dh5-chips--scroll {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.dh5-chip {
flex: 0 0 auto;
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
color: $dh5-text-mute;
background: $dh5-bg;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.18s, color 0.18s;
user-select: none;
white-space: nowrap;
line-height: 1;
em {
font-style: normal;
margin-left: 4px;
font-size: 12px;
opacity: 0.7;
}
&:active {
background: #e2e8f0;
}
&.active {
color: #fff;
background: $dh5-primary;
em { opacity: 0.85; }
}
}
.dh5-chip--booking.active { background: $dh5-warn; }
.dh5-chip--done.active { background: $dh5-success; }
.dh5-chip--pending.active { background: $dh5-danger; }
.dh5-quick {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-top: 10px;
}
.dh5-quick-group {
display: flex;
align-items: center;
gap: 6px;
}
.dh5-quick-label {
font-size: 12px;
color: $dh5-text-mute;
}
.dh5-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
color: $dh5-text-mute;
background: $dh5-bg;
cursor: pointer;
transition: background-color 0.18s, color 0.18s;
line-height: 1.4;
&:active { background: #e2e8f0; }
&.active {
color: $dh5-primary;
background: $dh5-primary-soft;
font-weight: 600;
}
}
.dh5-more-toggle {
margin-left: auto;
font-size: 12px !important;
.el-icon {
margin-left: 2px;
transition: transform 0.2s;
}
.rotate-180 { transform: rotate(180deg); }
}
.dh5-more-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed $dh5-line;
.el-select { flex: 1 1 calc(50% - 4px); min-width: 120px; }
}
/* ============= 列表 ============= */
.dh5-list {
padding: 10px 12px;
}
.dh5-empty {
padding: 60px 0;
}
.dh5-card {
background: $dh5-card-bg;
border-radius: 14px;
margin-bottom: 10px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
border: 1px solid transparent;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: transparent;
}
}
.dh5-card--unconfirmed::before { background: $dh5-danger; }
.dh5-card--noapt::before { background: $dh5-warn; }
.dh5-card-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px 8px;
cursor: pointer;
}
.dh5-card-avatar {
flex: 0 0 36px;
width: 36px;
height: 36px;
border-radius: 10px;
background: $dh5-primary-soft;
color: $dh5-primary;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.dh5-card-id-name {
flex: 1;
min-width: 0;
}
.dh5-card-name {
font-size: 16px;
font-weight: 600;
color: $dh5-text;
line-height: 1.2;
display: flex;
align-items: baseline;
gap: 6px;
flex-wrap: wrap;
}
.dh5-tag-new {
display: inline-block;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 3px;
margin-left: 4px;
vertical-align: middle;
}
.dh5-meta-gender,
.dh5-meta-age {
font-size: 12px;
color: $dh5-text-mute;
font-weight: 400;
}
.dh5-card-sub {
font-size: 12px;
color: $dh5-text-mute;
margin-top: 4px;
line-height: 1.2;
}
.dh5-card-tags {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.dh5-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
border-radius: 999px;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
}
.dh5-tag--ok { background: rgba(16, 185, 129, 0.10); color: #047857; }
.dh5-tag--warn { background: rgba(239, 68, 68, 0.10); color: #b91c1c; }
.dh5-tag--info { background: rgba(37, 99, 235, 0.10); color: #1d4ed8; }
.dh5-tag--gray { background: #f1f5f9; color: #64748b; }
.dh5-card-body {
padding: 4px 14px 12px;
cursor: pointer;
}
.dh5-row {
display: flex;
align-items: flex-start;
font-size: 13px;
line-height: 1.6;
gap: 8px;
padding: 4px 0;
}
.dh5-row-key {
flex: 0 0 44px;
color: $dh5-text-mute;
font-size: 12px;
padding-top: 1px;
}
.dh5-row-val {
flex: 1;
color: $dh5-text;
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.dh5-meta-doctor,
.dh5-meta-mute {
color: $dh5-text-mute;
font-size: 12px;
}
/* 挂号块 - 状态色 */
.dh5-row--apt {
background: #fafbfc;
border-radius: 10px;
padding: 8px 10px;
margin-bottom: 6px;
.dh5-row-key { padding-top: 2px; }
&.apt-row-done {
background: rgba(16, 185, 129, 0.06);
}
&.apt-row-missed {
background: rgba(148, 163, 184, 0.10);
}
}
.dh5-apt-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 6px;
background: rgba(37, 99, 235, 0.06);
font-size: 12px;
width: 100%;
}
.dh5-apt-item--done { background: rgba(16, 185, 129, 0.10); }
.dh5-apt-item--missed { background: rgba(148, 163, 184, 0.14); }
.dh5-apt-item--active { background: rgba(37, 99, 235, 0.10); }
.dh5-apt-status {
font-weight: 600;
font-size: 12px;
}
.dh5-apt-doctor {
color: $dh5-text;
font-weight: 500;
}
.dh5-apt-time {
color: $dh5-text-mute;
font-size: 12px;
margin-left: auto;
}
.dh5-apt-none {
color: $dh5-warn;
font-weight: 600;
font-size: 13px;
}
/* 视频旁观条 */
.dh5-card-watch {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: linear-gradient(90deg, rgba(37, 99, 235, 0.04), transparent);
border-top: 1px dashed $dh5-line;
font-size: 12px;
}
.dh5-watch-tip {
color: $dh5-text-mute;
font-size: 11px;
line-height: 1.3;
flex: 1;
}
.dh5-watch-mute {
color: $dh5-text-mute;
font-size: 12px;
}
/* 卡片底部按钮 */
.dh5-card-foot {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid #f1f5f9;
.el-button {
flex: 1;
min-width: 0;
padding: 0;
height: 32px;
font-size: 13px;
border-radius: 8px;
}
.dh5-more-btn {
flex: 0 0 auto;
min-width: 64px;
}
}
/* ============= FAB ============= */
.dh5-fab {
position: fixed;
right: 16px;
bottom: 28px;
width: 52px;
height: 52px;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #fff;
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.32);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 99;
transition: transform 0.18s ease;
&:active { transform: scale(0.94); }
}
/* ============= Load more / empty ============= */
.dh5-load-more {
text-align: center;
padding: 16px;
font-size: 13px;
color: $dh5-primary;
cursor: pointer;
}
.dh5-no-more {
text-align: center;
padding: 16px;
font-size: 12px;
color: #94a3b8;
}
/* ============= Action Sheet ============= */
.dh5-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 8px;
padding: 8px 4px 8px;
}
.dh5-action {
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 4px;
border-radius: 10px;
cursor: pointer;
color: $dh5-text;
font-size: 12px;
transition: background-color 0.18s;
.el-icon {
width: 36px;
height: 36px;
font-size: 18px;
background: $dh5-primary-soft;
color: $dh5-primary;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
&:active { background: $dh5-bg; }
&--danger {
color: $dh5-danger;
.el-icon {
background: rgba(239, 68, 68, 0.10);
color: $dh5-danger;
}
}
}
/* ============= 通用 form / drawer ============= */
.dh5-form {
:deep(.el-form-item__label) {
font-size: 13px !important;
color: $dh5-text-mute !important;
font-weight: 500 !important;
}
}
.dh5-form-tip {
font-size: 13px;
color: $dh5-text-mute;
line-height: 1.6;
}
.dh5-flex1 {
flex: 1;
}
/* ============= QR Dialog ============= */
.dh5-qr {
padding: 16px 4px 8px;
text-align: center;
}
.dh5-qr-loading {
padding: 24px 0;
color: $dh5-text-mute;
}
.dh5-qr-tip {
margin-top: 10px;
font-size: 13px;
}
.dh5-qr-img {
width: 220px;
height: 220px;
border-radius: 12px;
border: 1px solid $dh5-line;
}
.dh5-qr-meta {
margin-top: 12px;
color: $dh5-text-mute;
font-size: 13px;
line-height: 1.7;
.dh5-qr-hint { color: $dh5-text-mute; opacity: 0.85; }
}
.dh5-qr-fail {
padding: 24px;
color: $dh5-danger;
}
/* ============= 企微 Drawer ============= */
.dh5-wx-contact {
background: $dh5-bg;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 12px;
}
.dh5-wx-contact-row {
display: flex;
justify-content: space-between;
font-size: 13px;
line-height: 1.8;
}
.dh5-wx-key { color: $dh5-text-mute; }
.dh5-wx-val { color: $dh5-text; }
.dh5-wx-add {
display: flex;
gap: 8px;
margin-bottom: 12px;
.el-textarea { flex: 1; }
.el-button { align-self: flex-end; }
}
.dh5-wx-empty {
text-align: center;
color: #94a3b8;
padding: 32px 0;
}
.dh5-wx-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.dh5-wx-item {
background: #fff;
border: 1px solid $dh5-line;
border-radius: 10px;
padding: 10px;
}
.dh5-wx-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.dh5-wx-head-left,
.dh5-wx-head-right {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.dh5-wx-name { font-size: 13px; font-weight: 500; }
.dh5-wx-time { font-size: 11px; color: #94a3b8; }
.dh5-wx-content {
font-size: 13px;
color: $dh5-text;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.dh5-wx-media {
margin-top: 8px;
}
:deep(.el-drawer__footer) {
display: flex;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid $dh5-line;
}
</style>
<style lang="scss">
/* 仅 H5 模式下的全局微调(沿用旧代码的 body.h5-mode 上下文) */
body.h5-mode {
.el-drawer {
width: 100% !important;
max-width: 100vw !important;
}
.el-drawer__body {
padding: 0 14px 14px !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
}
.el-drawer__header {
padding: 14px 16px !important;
margin-bottom: 0 !important;
border-bottom: 1px solid #eee;
background: #fff;
.el-drawer__title {
font-size: 17px;
font-weight: 600;
}
}
.el-drawer__footer {
padding: 10px 16px !important;
border-top: 1px solid #eee;
background: #fff;
}
.el-overlay-dialog .el-dialog,
.el-dialog {
width: 92% !important;
max-width: 460px !important;
margin: 8vh auto !important;
border-radius: 12px !important;
}
.el-dialog__body {
max-height: 65vh;
overflow-y: auto;
}
.dialog .el-dialog {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
height: 100vh;
border-radius: 0 !important;
.el-dialog__body {
max-height: calc(100vh - 110px);
overflow-y: auto;
}
}
/* 编辑诊单 / 处方 等老抽屉的兼容样式(保留) */
.edit-drawer {
.el-row {
display: block !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.el-col {
max-width: 100% !important;
flex: 0 0 100% !important;
width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
}
.diagnosis-drawer {
.el-drawer__body { padding-bottom: 60px !important; }
.el-form {
padding-bottom: 10px;
.el-form-item {
flex-direction: column !important;
align-items: flex-start !important;
margin-bottom: 10px !important;
padding: 0 !important;
.el-form-item__label {
width: auto !important;
max-width: 100% !important;
min-width: 0 !important;
text-align: left !important;
padding: 0 0 3px 0 !important;
font-size: 14px !important;
font-weight: 500;
color: #475569;
line-height: 1.4 !important;
height: auto !important;
justify-content: flex-start !important;
}
.el-form-item__content {
margin-left: 0 !important;
width: 100% !important;
}
}
.el-input,
.el-input-number,
.el-select,
.el-date-editor,
.el-textarea {
width: 100% !important;
}
.el-input-number .el-input__wrapper { width: 100% !important; }
.el-input__wrapper,
.el-textarea__inner {
border-radius: 8px !important;
}
.el-radio-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
}
.el-divider {
margin: 14px 0 8px !important;
border-top: none !important;
.el-divider__text {
font-size: 15px;
font-weight: 600;
color: #303133;
background: #fff;
padding-left: 0 !important;
}
&::before { width: 0 !important; }
}
.checkbox-form-item,
.compact-form-item {
.el-checkbox-group,
.el-radio-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.el-checkbox-button,
.el-radio-button {
.el-checkbox-button__inner,
.el-radio-button__inner {
padding: 6px 14px !important;
font-size: 13px !important;
border-radius: 16px !important;
border: 1px solid #e0e0e0 !important;
background: #f9f9f9 !important;
color: #606266 !important;
transition: all 0.2s !important;
}
&.is-checked {
.el-checkbox-button__inner,
.el-radio-button__inner {
background: #ecf5ff !important;
border-color: #2563eb !important;
color: #2563eb !important;
}
}
}
.el-checkbox-button + .el-checkbox-button,
.el-radio-button + .el-radio-button {
margin-left: 0 !important;
.el-checkbox-button__inner,
.el-radio-button__inner {
border-left: 1px solid #e0e0e0 !important;
}
}
}
.form-tips {
font-size: 11px;
color: #b0b0b0;
line-height: 1.4;
margin-top: 4px;
}
}
.diagnosis-tabs {
.el-tabs__header {
position: sticky;
top: 0;
z-index: 10;
background: #fff;
margin: 0 0 6px 0 !important;
padding-top: 2px;
}
.el-tabs__nav-wrap::after { height: 1px !important; }
.el-tabs__item { font-size: 14px !important; }
}
/* 预约时间网格(保留) */
.appointment-form {
.el-form-item {
flex-direction: column !important;
align-items: flex-start !important;
margin-bottom: 12px !important;
.el-form-item__label {
width: auto !important;
min-width: 0 !important;
text-align: left !important;
padding: 0 0 4px 0 !important;
font-size: 13px !important;
font-weight: 500;
color: #8c8c8c;
line-height: 1.4 !important;
height: auto !important;
justify-content: flex-start !important;
}
.el-form-item__content {
margin-left: 0 !important;
width: 100% !important;
}
}
.el-radio-group {
display: flex !important;
flex-wrap: wrap !important;
gap: 8px !important;
}
.el-radio { margin-right: 0 !important; }
.el-textarea__inner { border-radius: 8px !important; }
}
.appointment-time-container { width: 100% !important; }
.date-selector {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 8px !important;
> .el-button,
> button {
min-width: 0 !important;
width: 100% !important;
height: 40px !important;
font-size: 13px !important;
padding: 0 4px !important;
border-radius: 10px !important;
box-sizing: border-box !important;
margin-left: 0 !important;
}
}
.time-slots-container {
padding: 12px !important;
border-radius: 10px !important;
background-color: #f7f8fa !important;
}
.time-slots-grid {
grid-template-columns: repeat(3, 1fr) !important;
gap: 8px !important;
max-height: 300px !important;
.time-slot-item {
min-height: 56px !important;
padding: 6px 4px !important;
border-radius: 10px !important;
border-width: 1.5px !important;
}
}
.drawer-footer { padding: 0 !important; }
.detail-popup {
.el-descriptions__body .el-descriptions__table {
display: block !important;
tbody { display: block !important; }
.el-descriptions-row {
display: block !important;
border-bottom: none !important;
.el-descriptions__cell {
display: block !important;
width: 100% !important;
border: none !important;
&.el-descriptions__label {
background: transparent;
font-weight: 500;
font-size: 13px;
color: #999;
padding: 10px 0 2px 0 !important;
border-bottom: none !important;
}
&.el-descriptions__content {
font-size: 14px;
color: #303133;
padding: 2px 0 10px 0 !important;
border-bottom: 1px solid #f5f5f5 !important;
}
}
}
}
}
}
</style>