This commit is contained in:
Your Name
2026-08-12 18:09:57 +08:00
parent a1092c02c3
commit b2eb18c75f
391 changed files with 1666 additions and 638 deletions
+139 -40
View File
@@ -1,8 +1,13 @@
<template>
<div ref="paperRef" class="rx-paper">
<div class="rx-title">{{ isInternal ? '药房联' : slipTitle }}</div>
<div class="rx-notice">
<span class="rx-notice-text">
服药前请核对姓名电话医生等信息以及服法医嘱等要点
{{
isInternal
? '服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点'
: '服药前请核对姓名、电话、医生等信息以及医嘱等要点'
}}
</span>
<span class="rx-notice-meta">
<span>日期{{ dateText }}</span>
@@ -44,63 +49,98 @@
</div>
<div class="rx-rp">
<div class="rx-watermark">药房联</div>
<div class="rx-watermark">{{ isInternal ? '药房联' : '处方联' }}</div>
<div class="rx-rp-head">
<div class="rx-rp-label">Rp.</div>
<div class="rx-rp-cols-head">
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ isInternal ? '总量' : '用量' }}</span>
</div>
<div class="rx-rp-col-head">
<span>用药 (单剂)</span>
<span>总量</span>
<span>{{ isInternal ? '总量' : '用量' }}</span>
</div>
</div>
</div>
<div class="rx-herbs">
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
<template v-if="isInternal">
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">
辅方
<span v-if="auxLibraryName" class="rx-herb-library-name">{{ auxLibraryName }}</span>
</div>
<div
v-for="(h, i) in auxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
</template>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in auxHerbs"
:key="'aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }} ({{ h.dosage }})</span>
<span class="rx-herb-total">{{ herbTotal(h.dosage) }}</span>
</div>
<template v-else>
<template v-if="mainHerbs.length">
<div class="rx-herb-section-label">主方</div>
<div
v-for="(h, i) in mainHerbs"
:key="'user-main-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
<template v-if="auxHerbs.length">
<div class="rx-herb-section-label rx-herb-section-label--aux">辅方</div>
<div
v-for="(h, i) in auxHerbs"
:key="'user-aux-' + i"
class="rx-herb-cell"
>
<span class="rx-herb-name">{{ h.name }}</span>
<span class="rx-herb-total">{{ h.dosage }}</span>
</div>
</template>
</template>
</div>
</div>
<div class="rx-text">
<p>主方服法{{ usageText }}</p>
<p v-if="auxUsageText">辅方服法{{ auxUsageText }}</p>
<template v-if="isInternal">
<p v-if="auxUsageText">服法{{ usageText }}</p>
<p v-else>服法{{ usageText }}</p>
<p v-if="auxUsageText">辅服法{{ auxUsageText }}</p>
</template>
<p v-if="adviceText">医嘱{{ adviceText }}</p>
<p v-if="dietaryText">忌口{{ dietaryText }}</p>
<p v-if="remarkText">备注{{ remarkText }}</p>
<p v-if="isInternal && remarkText">备注{{ remarkText }}</p>
<p v-if="pharmacyRemarkText" class="rx-text-warn">
药房备注{{ pharmacyRemarkText }}
</p>
<p v-if="outPelletText" class="rx-text-warn">
<p
v-if="isInternal && outPelletText && data?.prescription_type !== '饮片'"
class="rx-text-warn"
>
出丸{{ outPelletText }}
</p>
</div>
<div class="rx-bottom">
<div class="rx-bot-row rx-bot-row-1">
<div class="rx-bot-row rx-bot-row-1" :class="{ 'rx-bot-row-1--user': !isInternal }">
<div class="rx-bot-cell rx-bot-stack rx-bot-doctor">
<div class="rx-bot-label">医师</div>
<div class="rx-bot-doctor-body">
@@ -115,19 +155,23 @@
</div>
</div>
</div>
<div class="rx-bot-cell rx-bot-meta">
<div v-if="isInternal" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">类型:</span>
<span class="rx-bot-meta-val">{{ typeText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">天数:</span>
<span class="rx-bot-meta-val">{{ data?.dose_count || '—' }}</span>
<span class="rx-bot-meta-val">{{ daysText }}</span>
</div>
<div class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<div v-if="isInternal" class="rx-bot-cell rx-bot-meta">
<span class="rx-bot-meta-key">剂量:</span>
<span class="rx-bot-meta-val">{{ perDoseAmount }}</span>
</div>
</div>
<div class="rx-bot-row rx-hospital-row">
<div class="rx-hospital-line">成都双流甄养堂互联网医院有限公司 联系方式4001667339</div>
<div class="rx-hospital-line">地址四川省成都市双流区黄甲街道黄龙大道二段280号</div>
</div>
</div>
</div>
</template>
@@ -156,12 +200,27 @@ import {
defineOptions({ name: 'PrescriptionSlip' })
const props = defineProps<{
/** 处方详情 / 列表行(字段与 tcm.prescription/detail 对齐) */
data: Record<string, any> | null | undefined
}>()
const props = withDefaults(
defineProps<{
/** 处方详情 / 列表行(字段与 tcm.prescription/detail 对齐) */
data: Record<string, any> | null | undefined
/** 药房联 internal / 处方联 user */
variant?: 'internal' | 'user'
/** 药房联辅方处方库名称 */
auxLibraryName?: string
/** 抬头(处方联) */
title?: string
}>(),
{
variant: 'internal',
auxLibraryName: '',
title: '成都双流甄养堂互联网医院 处方笺'
}
)
const paperRef = ref<HTMLElement | null>(null)
const isInternal = computed(() => props.variant !== 'user')
const slipTitle = computed(() => props.title || '成都双流甄养堂互联网医院 处方笺')
const dateText = computed(() => rxDateText(props.data))
const serialText = computed(() => rxSerialText(props.data))
@@ -179,6 +238,17 @@ const pharmacyRemarkText = computed(() => rxPharmacyRemarkText(props.data))
const outPelletText = computed(() => rxOutPelletText(props.data))
const typeText = computed(() => rxTypeText(props.data))
const perDoseAmount = computed(() => rxPerDoseAmount(props.data))
const daysText = computed(() => {
const md = props.data?.medication_days
if (md != null && String(md).trim() !== '') {
return `${md}`
}
const dose = props.data?.dose_count
if (dose != null && String(dose).trim() !== '') {
return `${dose}`
}
return '—'
})
function herbTotal(dosage: number | string) {
return rxHerbTotal(props.data, dosage)
@@ -194,9 +264,8 @@ defineExpose({
<style lang="scss" scoped>
/* ============================================================
* 处方笺(A4 药房联)样式
* 处方笺(A4 药房联 / 处方联)样式
* 严格按 A4210mm × 297mmpadding 8mm 10mm
* 唯一视觉基准:consumer/prescription/index.vue
* ============================================================ */
.rx-paper {
width: 210mm;
@@ -214,6 +283,15 @@ defineExpose({
overflow: visible;
}
.rx-title {
text-align: center;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.12em;
margin: 0 0 8px;
color: #1f1f1f;
}
.rx-notice {
display: flex;
align-items: center;
@@ -374,6 +452,11 @@ defineExpose({
margin-top: 4px;
}
.rx-herb-library-name {
font-weight: 500;
margin-left: 2px;
}
.rx-herb-cell {
display: grid;
grid-template-columns: 1fr 64px;
@@ -430,10 +513,14 @@ defineExpose({
}
.rx-bot-row-1 {
grid-template-columns: 1.4fr 0.6fr 0.6fr 0.6fr 0.6fr 1.7fr 0.95fr 1.25fr;
grid-template-columns: 1.6fr 0.9fr 0.9fr 0.9fr;
min-height: 70px;
}
.rx-bot-row-1--user {
grid-template-columns: 2fr 1fr;
}
.rx-bot-cell {
border-right: 1px solid #c8c8c8;
padding: 6px 10px;
@@ -504,6 +591,18 @@ defineExpose({
line-height: 1.4;
}
}
.rx-hospital-row {
display: block;
padding: 8px 10px;
background: #fafafa;
}
.rx-hospital-line {
font-size: 12px;
color: #606266;
line-height: 1.7;
}
</style>
<!-- 全局打印样式body * 选择器不能放进 scoped -->
@@ -1770,6 +1770,17 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 药房联 / 处方联切换 -->
<el-tabs
v-if="prescriptionViewData"
v-model="prescriptionTabType"
class="mx-4 mt-2"
style="margin-bottom: 0; z-index: 1; position: relative"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条不进入打印/导出范围 -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -1822,6 +1833,9 @@
v-if="prescriptionViewData"
ref="rxSlipRef"
:data="prescriptionViewData"
:variant="prescriptionTabType"
:aux-library-name="slipAuxLibraryName"
:title="SLIP_TITLE"
/>
<!-- 审核痕迹不进入导出 -->
@@ -4466,6 +4480,8 @@ const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
/** 药房联辅方标题:处方库中该辅方模板的 prescription_name(导入或药材匹配解析) */
const slipAuxLibraryName = ref('')
/** 查看处方联次:药房联 / 处方联 */
const prescriptionTabType = ref<'internal' | 'user'>('internal')
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -4864,6 +4880,7 @@ async function openPrescriptionView(row: any) {
prescriptionViewLoading.value = true
prescriptionViewData.value = null
slipAuxLibraryName.value = ''
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
@@ -2342,6 +2342,17 @@
</div>
</template>
<div v-loading="prescriptionViewLoading" class="rx-wrap">
<!-- 药房联 / 处方联切换 -->
<el-tabs
v-if="prescriptionViewData"
v-model="prescriptionTabType"
class="mx-4 mt-2"
style="margin-bottom: 0; z-index: 1; position: relative"
>
<el-tab-pane label="药房联" name="internal" />
<el-tab-pane label="处方联" name="user" />
</el-tabs>
<!-- 状态条不进入打印/导出范围 -->
<div class="rx-statusbar" v-if="prescriptionViewData">
<el-tag
@@ -2394,6 +2405,8 @@
v-if="prescriptionViewData"
ref="rxSlipRef"
:data="prescriptionViewData"
:variant="prescriptionTabType"
:title="SLIP_TITLE"
/>
<!-- 审核痕迹不进入导出 -->
@@ -5296,6 +5309,8 @@ const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄
const prescriptionViewVisible = ref(false)
const prescriptionViewLoading = ref(false)
const prescriptionViewData = ref<any>(null)
/** 查看处方联次:药房联 / 处方联 */
const prescriptionTabType = ref<'internal' | 'user'>('internal')
const rxSlipRef = ref<InstanceType<typeof PrescriptionSlip> | null>(null)
const prescriptionSlipExporting = ref(false)
@@ -5563,7 +5578,8 @@ async function openPrescriptionView(row: any) {
prescriptionViewVisible.value = true
prescriptionViewLoading.value = true
prescriptionViewData.value = null
prescriptionTabType.value = 'internal'
try {
const res: any = await prescriptionDetail({ id: row.prescription_id })
const base = res?.data ?? res ?? null
@@ -1,92 +1,100 @@
<template>
<section class="embedded-panel" v-loading="loading">
<div class="panel-toolbar">
<div>
<h2>医生排班</h2>
<p>查看近 7 日出诊医生及可约时段与医生排班管理同口径</p>
</div>
<div class="toolbar-actions">
<el-button
:icon="Refresh"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
刷新时段
</el-button>
</div>
</div>
<el-form :model="form" label-width="88px" class="paiban-form">
<el-form-item label="选择医生">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
{{ doctor.name }}
</el-radio>
</el-radio-group>
<el-empty
v-if="!loading && doctorList.length === 0"
description="暂无可用医生"
:image-size="64"
/>
<div class="paiban-container" v-loading="loading">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>医生排班管理</span>
<el-button
text
type="primary"
size="small"
:loading="refreshing"
:disabled="!selectedDoctorId || !form.date"
@click="handleRefreshSlots"
>
<template #icon>
<Refresh />
</template>
刷新
</el-button>
</div>
</el-form-item>
</template>
<el-form-item label="排班时间">
<el-empty v-if="!selectedDoctorId" description="请先选择医生" :image-size="72" />
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="72"
/>
<div v-else class="paiban-time-container">
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<div v-if="form.date" class="time-slots-container">
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
available: slot.available,
unavailable: !slot.available,
selected: form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
<el-form :model="form" label-width="100px" class="paiban-form">
<!-- 选择医生 -->
<el-form-item label="选择医生:">
<div class="doctor-list">
<el-radio-group v-model="selectedDoctorId" @change="handleDoctorChange">
<el-radio
v-for="doctor in doctorList"
:key="doctor.id"
:value="doctor.id"
class="doctor-radio"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
{{ doctor.name }}
</el-radio>
</el-radio-group>
</div>
</el-form-item>
<!-- 排班时间 -->
<el-form-item label="排班时间:">
<!-- 未选择医生提示 -->
<el-empty
v-if="!selectedDoctorId"
description="请先选择医生"
:image-size="80"
/>
<!-- 医生无排班提示 -->
<el-empty
v-else-if="selectedDoctorId && doctorRosterDates.length === 0"
description="该医生暂无排班"
:image-size="80"
/>
<!-- 有排班时显示日期和时间段 -->
<div v-else class="paiban-time-container">
<!-- 日期选择 -->
<div class="date-selector">
<el-button
v-for="dateOption in dateOptions"
:key="dateOption.date"
:type="form.date === dateOption.date ? 'primary' : ''"
class="date-button"
@click="selectDate(dateOption.date)"
>
{{ dateOption.label }}
</el-button>
</div>
<!-- 时间段区域 -->
<div v-if="form.date" class="time-slots-container">
<!-- 时间段网格 -->
<div class="time-slots-grid">
<div
v-for="slot in filteredTimeSlots"
:key="slot.time"
class="time-slot-item"
:class="{
available: slot.available,
unavailable: !slot.available,
selected: form.selectedTime === slot.time
}"
@click="selectTimeSlot(slot)"
>
<div class="slot-time">{{ slot.time }}</div>
<div class="slot-status" :class="{ 'status-available': slot.available }">
{{ slot.available ? '可约' : slot.hasAppointment ? '已约' : '空号' }}
</div>
</div>
</div>
</div>
<el-empty
v-if="filteredTimeSlots.length === 0"
description="当前日期暂无可展示时段"
:image-size="64"
/>
</div>
</div>
</el-form-item>
</el-form>
</section>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
@@ -164,17 +172,21 @@ const filteredTimeSlots = computed(() => {
const slotDateTime = dayjs(`${form.date} ${slot.time}`)
const isPast = slotDateTime.isBefore(now) || slotDateTime.isSame(now, 'minute')
if (isPast) {
return { ...slot, available: false }
return {
...slot,
available: false
}
}
return slot
})
})
async function loadDoctors() {
const loadDoctors = async () => {
try {
loading.value = true
const res = await getDoctors()
doctorList.value = res || []
if (doctorList.value.length > 0 && !selectedDoctorId.value) {
selectedDoctorId.value = doctorList.value[0].id
await handleDoctorChange()
@@ -187,25 +199,28 @@ async function loadDoctors() {
}
}
async function handleDoctorChange() {
const handleDoctorChange = async () => {
form.selectedTime = ''
form.date = ''
timeSlots.value = []
doctorRosterDates.value = []
if (selectedDoctorId.value) {
await loadDoctorRoster()
}
}
async function loadDoctorRoster() {
const loadDoctorRoster = async () => {
if (!selectedDoctorId.value) {
return
}
try {
loading.value = true
const startDate = dayjs().format('YYYY-MM-DD')
const endDate = dayjs().add(6, 'day').format('YYYY-MM-DD')
const res = await rosterLists({
doctor_id: selectedDoctorId.value,
start_date: startDate,
@@ -216,6 +231,7 @@ async function loadDoctorRoster() {
if (res?.lists && res.lists.length > 0) {
doctorRosterDates.value = [...new Set(res.lists.map((item: any) => item.date))] as string[]
doctorRosterDates.value.sort()
await nextTick()
if (doctorRosterDates.value.length > 0) {
const today = dayjs().format('YYYY-MM-DD')
@@ -237,7 +253,7 @@ async function loadDoctorRoster() {
}
}
function selectDate(date: string) {
const selectDate = (date: string) => {
form.date = date
form.selectedTime = ''
if (selectedDoctorId.value) {
@@ -245,16 +261,19 @@ function selectDate(date: string) {
}
}
async function loadTimeSlots(silent = false) {
const loadTimeSlots = async (silent = false) => {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
console.log('正在加载中,跳过本次请求')
return
}
try {
isLoadingSlots = true
if (!silent) {
loading.value = true
}
@@ -285,7 +304,7 @@ async function loadTimeSlots(silent = false) {
}
}
function selectTimeSlot(slot: TimeSlot) {
const selectTimeSlot = (slot: TimeSlot) => {
if (!slot.available) {
feedback.msgWarning('该时间段不可预约')
return
@@ -293,10 +312,11 @@ function selectTimeSlot(slot: TimeSlot) {
form.selectedTime = slot.time
}
async function handleRefreshSlots() {
const handleRefreshSlots = async () => {
if (!selectedDoctorId.value || !form.date) {
return
}
if (isLoadingSlots) {
feedback.msgWarning('正在刷新中,请稍候')
return
@@ -306,13 +326,16 @@ async function handleRefreshSlots() {
refreshing.value = true
const previousSelection = form.selectedTime
form.selectedTime = ''
await loadTimeSlots()
if (previousSelection) {
const slot = timeSlots.value.find((s) => s.time === previousSelection)
if (slot?.available) {
if (slot && slot.available) {
form.selectedTime = previousSelection
}
}
feedback.msgSuccess('刷新成功')
} catch (error) {
console.error('刷新失败:', error)
@@ -322,8 +345,9 @@ async function handleRefreshSlots() {
}
}
function startAutoRefresh() {
const startAutoRefresh = () => {
stopAutoRefresh()
autoRefreshTimer = window.setInterval(() => {
if (selectedDoctorId.value && form.date) {
loadTimeSlots(true)
@@ -331,7 +355,7 @@ function startAutoRefresh() {
}, 5000)
}
function stopAutoRefresh() {
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
@@ -362,36 +386,16 @@ onUnmounted(() => {
</script>
<style scoped lang="scss">
.embedded-panel {
padding-top: 4px;
.paiban-container {
padding: 0;
}
.panel-toolbar {
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f2a37;
}
p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
}
.paiban-form {
@@ -404,10 +408,14 @@ onUnmounted(() => {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
.doctor-radio {
margin-right: 0;
:deep(.el-radio__label) {
display: flex;
align-items: center;
}
}
}
@@ -419,7 +427,7 @@ onUnmounted(() => {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
margin-bottom: 20px;
.date-button {
min-width: 130px;
@@ -441,6 +449,19 @@ onUnmounted(() => {
padding: 16px;
}
.time-slots-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.header-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
.time-slots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
@@ -467,7 +488,7 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 8px;
padding: 0px 8px;
border: 2px solid #e4e7ed;
border-radius: 8px;
cursor: pointer;
@@ -485,7 +506,7 @@ onUnmounted(() => {
.slot-status {
font-size: 12px;
color: #909399;
padding: 0 8px;
padding: 0px 8px;
border-radius: 4px;
background-color: #f4f4f5;