This commit is contained in:
gr
2026-10-15 11:03:54 +08:00
parent 70ddc1a70a
commit bb28d93cec
64 changed files with 12747 additions and 420 deletions
+286 -81
View File
@@ -14,6 +14,7 @@
formData.pending_assign !== '1' &&
formData.pending_booking !== '1' &&
formData.completed_appointment !== '1' &&
formData.revisit_state === '' &&
!hasLatestAppointmentFilter() &&
formData.appointment_date === tab.value
}
@@ -30,6 +31,14 @@
待预约
<em v-if="pendingBookingCount !== undefined">{{ pendingBookingCount }}</em>
</span>
<!-- 复诊到期:到期未挂号 ∪ 过号未面诊(revisit_state=due),点击默认按药尽日升序 -->
<span
:class="['chip', 'chip-revisit-due', { active: formData.revisit_state === 'due' }]"
@click="handleRevisitDueTabClick"
>
复诊到期
<em v-if="revisitDueCount !== undefined">{{ revisitDueCount }}</em>
</span>
<span
:class="['chip', 'chip-completed-visit', { active: formData.completed_appointment === '1' }]"
@click="handleCompletedVisitTabClick"
@@ -147,6 +156,36 @@
:value="String(item.value)"
/>
</el-select>
<el-select
v-model="formData.revisit_state"
placeholder="复诊状态"
clearable
size="small"
class="revisit-state-select"
@change="handleRevisitFilterChange"
>
<el-option
v-for="item in revisitStateFilterOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="formData.revisit_days_bucket"
placeholder="开药天数档"
clearable
size="small"
class="revisit-days-bucket-select"
@change="doSearch"
>
<el-option
v-for="item in revisitDaysBucketOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button size="small" @click="handleReset">重置</el-button>
</div>
</el-card>
@@ -278,22 +317,20 @@
<span v-else class="status-unconfirmed">未确认</span>
</template>
</el-table-column>
<el-table-column label="复诊" min-width="120">
<!-- 复诊节点:吞掉原「复诊」「开方」两列;排序走后端 sort_revisit_due(药尽日,NULL 置后) -->
<el-table-column
label="复诊节点"
prop="revisit_due_date"
min-width="180"
sortable="custom"
>
<template #default="{ row }">
<div v-if="row.has_prescription" class="followup-cell">
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
<el-tag
v-if="row.followup_rx_voided"
type="info"
size="small"
effect="plain"
class="followup-void-tag"
>
处方已作废
</el-tag>
</div>
<span v-else class="followup-none">无</span>
<revisit-node-cell
:due="revisitDueOf(row)"
:has-prescription="!!row.has_prescription"
:followup-doctor-name="row.followup_doctor_name || ''"
:prescribed-text="prescribedTextOf(row)"
/>
</template>
</el-table-column>
<el-table-column label="助理" width="100" show-overflow-tooltip>
@@ -301,12 +338,6 @@
<span class="assistant-cell">{{ getAssistantName(row.assistant_id || row.assistant) }}</span>
</template>
</el-table-column>
<el-table-column label="开方" width="72" align="center">
<template #default="{ row }">
<span v-if="row.has_prescription" class="status-prescribed">已开方</span>
<span v-else class="status-unprescribed">未开方</span>
</template>
</el-table-column>
<el-table-column
label="未服务天数"
prop="unserved_days"
@@ -519,7 +550,6 @@
</el-form-item>
<el-form-item label="继承">
<el-checkbox v-model="assignForm.is_inherit">继承</el-checkbox>
<span class="text-gray-400 text-xs ml-2">勾选后写入指派记录</span>
</el-form-item>
<el-form-item v-if="assignMode === 'batch'" label="已选择">
<div class="text-gray-500">{{ batchAssignIds.length }} 条诊单记录</div>
@@ -737,7 +767,7 @@
</template>
<script setup lang="ts" name="tcmDiagnosis">
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard } from '@/api/tcm'
import { tcmDiagnosisLists, tcmDiagnosisDelete, tcmDiagnosisAssign, tcmDiagnosisGuahaoLogList, getAssistants, generateMiniProgramQrcode, generateOrderQrcode, getWechatChatRecords, addWechatChatRecord, deleteWechatChatRecord, getWechatExternalContact, fillIdCard, diagnosisReadonlyDetail } from '@/api/tcm'
import { orderCreate } from '@/api/order'
import { cancelAppointment } from '@/api/doctor'
import { usePaging } from '@/hooks/usePaging'
@@ -752,6 +782,15 @@ import { useRoute, useRouter } from 'vue-router'
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
import dayjs from 'dayjs'
import DaterangePicker from '@/components/daterange-picker/index.vue'
import RevisitNodeCell from './components/RevisitNodeCell.vue'
import { formatGender } from '@/utils/diag-display'
import {
normalizeRevisitDue,
REVISIT_DAYS_BUCKET_OPTIONS,
REVISIT_STATE_FILTER_OPTIONS,
revisitRowClassName,
type RevisitDue
} from '@/utils/revisitDue'
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
@@ -798,6 +837,12 @@ const formData = reactive({
latest_assign_end_date: '' as string,
/** 未服务天数排序:desc=天数多到少 asc=少到多 */
sort_unserved_days: '' as '' | 'asc' | 'desc',
/** 复诊状态筛选:due(到期未挂号∪过号)|warning|booked|visited|contacted|no_days|pending,空=不限 */
revisit_state: '' as string,
/** 开药天数档筛选:14|21|28|30|other,空=不限 */
revisit_days_bucket: '' as string,
/** 复诊节点(药尽日)排序:asc=最早到期在前(NULL 置后) desc 反之;与 sort_unserved_days 互斥 */
sort_revisit_due: '' as '' | 'asc' | 'desc',
diagnosis_confirmed: '' as '' | '0' | '1',
appointment_date: '' as string,
has_appointment: '' as '' | '0' | '1',
@@ -898,11 +943,13 @@ function buildSharedDiagnosisFilterPayload(): Record<string, unknown> {
latest_appointment_end_date: formData.latest_appointment_end_date,
latest_appointment_channel_source: formData.latest_appointment_channel_source,
latest_assign_start_date: formData.latest_assign_start_date,
latest_assign_end_date: formData.latest_assign_end_date
latest_assign_end_date: formData.latest_assign_end_date,
revisit_state: formData.revisit_state,
revisit_days_bucket: formData.revisit_days_bucket
}
}
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件 */
/** 顶部 Tab 角标 count 请求:带上共用筛选,再叠加各 Tab 专属条件(revisit_state 视为 Tab 条件,角标间互不影响) */
function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
page_no: 1,
@@ -915,6 +962,8 @@ function buildDateCountRequestPayload(overrides: Record<string, unknown> = {}):
pending_assign_order_month: '',
pending_assign_keyword: '',
sort_unserved_days: '',
revisit_state: '',
sort_revisit_due: '',
...overrides
}
}
@@ -960,6 +1009,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.pending_assign_order_month = ''
activeTab.value = 'all'
if (kw1 !== '') {
@@ -996,6 +1048,7 @@ const handleDateTabClick = async (value: string) => {
formData.pending_booking = ''
formData.completed_appointment = ''
formData.has_appointment = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
formData.appointment_date = value
pager.page = 1
@@ -1012,6 +1065,7 @@ const handlePendingBookingTabClick = async () => {
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = '0'
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1027,6 +1081,7 @@ const handleCompletedVisitTabClick = async () => {
formData.completed_appointment = '1'
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
pager.page = 1
await getLists()
@@ -1039,6 +1094,7 @@ const handlePendingAssignTabClick = async () => {
formData.completed_appointment = ''
formData.has_appointment = ''
formData.appointment_date = ''
clearRevisitTabFilters()
clearLatestAppointmentFilters()
if (!formData.pending_assign_order_month) {
formData.pending_assign_order_month = dayjs().format('YYYY-MM')
@@ -1057,10 +1113,66 @@ const onPendingAssignOrderMonthChange = async (val: string | null) => {
fetchDateCounts()
}
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量
/** 复诊状态 / 开药天数档 下拉选项(文案与请求值映射在 utils/revisitDue.ts) */
const revisitStateFilterOptions = REVISIT_STATE_FILTER_OPTIONS
const revisitDaysBucketOptions = REVISIT_DAYS_BUCKET_OPTIONS
/** 顶部「复诊到期」角标:revisit_state=due 的 count */
const revisitDueCount = ref<number | undefined>(undefined)
/** 切换其它顶部 Tab 时清掉复诊 chip 状态与其排序(revisit_days_bucket 属「更多筛选」,保留) */
const clearRevisitTabFilters = () => {
formData.revisit_state = ''
formData.sort_revisit_due = ''
}
/** 顶部「复诊到期」chip:到期未挂号 ∪ 过号未面诊,默认按药尽日升序(超期最多在前) */
const handleRevisitDueTabClick = async () => {
formData.pending_assign = ''
formData.pending_assign_order_month = ''
formData.pending_assign_keyword = ''
formData.pending_booking = ''
formData.completed_appointment = ''
formData.appointment_date = ''
formData.has_appointment = ''
clearLatestAppointmentFilters()
formData.revisit_state = 'due'
formData.sort_revisit_due = 'asc'
formData.sort_unserved_days = ''
pager.page = 1
await getLists()
fetchDateCounts()
}
/** 「更多筛选 → 复诊状态」下拉:与顶部日期 Tab 互斥(同 chip 语义),选中后不再默认当天挂号 */
const handleRevisitFilterChange = () => {
if (formData.revisit_state !== '') {
formData.appointment_date = ''
formData.pending_booking = ''
formData.completed_appointment = ''
const noSortYet = formData.sort_revisit_due === '' && formData.sort_unserved_days === ''
if (formData.revisit_state === 'due' && noSortYet) {
formData.sort_revisit_due = 'asc'
}
} else {
formData.sort_revisit_due = ''
}
doSearch()
}
/** 列表行 revisit_due → 规整对象(旧接口无字段时为 null,单元格显示「—」) */
const revisitDueOf = (row: any): RevisitDue | null => normalizeRevisitDue(row?.revisit_due)
/** 「复诊节点」行 1 的开方文案:已开方 / 已开方(处方已作废) / 未开方 */
const prescribedTextOf = (row: any): string => {
if (!row?.has_prescription) return '未开方'
return row.followup_rx_voided ? '已开方(处方已作废)' : '已开方'
}
// 获取各日期挂号数量 + 全部 + 待预约 + 已完成 + 待分配医助数量 + 复诊到期数量
const fetchDateCounts = async () => {
try {
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending] = await Promise.all([
const [yesterday, dayBefore, today, tomorrow, dayAfter, all, noApt, doneVisit, pending, revisitDue] = await Promise.all([
tcmDiagnosisLists(
buildDateCountRequestPayload({ appointment_date: yesterdayStr.value, has_appointment: '' }) as any
),
@@ -1081,7 +1193,10 @@ const fetchDateCounts = async () => {
tcmDiagnosisLists(
buildDateCountRequestPayload({ completed_appointment: 1, has_appointment: '' }) as any
),
tcmDiagnosisLists(buildPendingAssignCountPayload() as any)
tcmDiagnosisLists(buildPendingAssignCountPayload() as any),
tcmDiagnosisLists(
buildDateCountRequestPayload({ revisit_state: 'due', has_appointment: '' }) as any
)
])
dateCounts.value = {
[yesterdayStr.value]: yesterday?.count ?? 0,
@@ -1094,6 +1209,7 @@ const fetchDateCounts = async () => {
pendingBookingCount.value = noApt?.count ?? 0
completedVisitCount.value = doneVisit?.count ?? 0
pendingAssignCount.value = pending?.count ?? 0
revisitDueCount.value = revisitDue?.count ?? 0
} catch (e) {
console.error('获取日期数量失败', e)
}
@@ -1252,12 +1368,11 @@ const handleTableSortChange = ({
prop: string
order: 'ascending' | 'descending' | null
}) => {
if (prop === 'unserved_days') {
formData.sort_unserved_days =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
} else {
formData.sort_unserved_days = ''
}
const dir: '' | 'asc' | 'desc' =
order === 'ascending' ? 'asc' : order === 'descending' ? 'desc' : ''
// 按列 prop 分派;两种排序后端互斥,切换列时清掉另一列
formData.sort_unserved_days = prop === 'unserved_days' ? dir : ''
formData.sort_revisit_due = prop === 'revisit_due_date' ? dir : ''
pager.page = 1
getLists()
}
@@ -1311,6 +1426,9 @@ const handleReset = () => {
formData.latest_assign_start_date = ''
formData.latest_assign_end_date = ''
formData.sort_unserved_days = ''
formData.revisit_state = ''
formData.revisit_days_bucket = ''
formData.sort_revisit_due = ''
formData.diagnosis_confirmed = ''
formData.appointment_date = ''
formData.has_appointment = ''
@@ -1343,8 +1461,10 @@ const unservedDaysClass = (n: unknown) => {
return 'unserved-green'
}
// 行高亮:未确认诊单优先,其次未挂号
// 行高亮优先级(方案 D3-c 默认 overdue_first):复诊超期/过号 > 未确认 > 未挂号
const getRowClassName = ({ row }: { row: any }) => {
const revisitCls = revisitRowClassName(revisitDueOf(row))
if (revisitCls) return revisitCls
if (!isDiagnosisConfirmed(row)) return 'row-unconfirmed'
if (!row.has_appointment) return 'row-no-appointment'
return ''
@@ -1356,6 +1476,8 @@ const prescriptionRef = ref()
const appointmentRef = ref()
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
const pendingRouteDiagnosisId = ref<number | null>(null)
/** 路由 ?id=&action=appointment 深链(复诊到期弹窗「去挂号」):挂号弹窗同为异步组件,先暂存行再 open */
const pendingRouteAppointmentRow = ref<Record<string, any> | null>(null)
const userStore = useUserStore()
watch(
@@ -1370,6 +1492,53 @@ watch(
{ flush: 'post' }
)
watch(
[appointmentRef, pendingRouteAppointmentRow],
() => {
const row = pendingRouteAppointmentRow.value
const opener = appointmentRef.value as { open?: (row: any) => void } | undefined
if (!row || !opener?.open) return
pendingRouteAppointmentRow.value = null
handleAppointment(row)
},
{ flush: 'post' }
)
/**
* 深链打开挂号弹窗:优先用当前列表里的行(字段最全);不在本页时回退只读详情接口拼最小行
* (appointment.vue open() 只读 patient_id / patient_name / gender_desc / age)。
*/
const openAppointmentFromRoute = async (diagnosisId: number) => {
// 深链打开挂号弹窗前先校验挂号权限(与行内「预约」按钮 v-perms 同口径)
if (!hasPermission(['tcm.diagnosis/guahao'])) {
feedback.msgWarning('无挂号权限')
return
}
const inList = (pager.lists as any[]).find((r) => Number(r?.id) === diagnosisId)
if (inList) {
pendingRouteAppointmentRow.value = inList
return
}
try {
const res: any = await diagnosisReadonlyDetail({ id: diagnosisId })
const diag = res?.diagnosis
if (!diag || !diag.patient_id) {
feedback.msgWarning('未找到该诊单或患者信息不完整,无法打开挂号')
return
}
pendingRouteAppointmentRow.value = {
...diag,
id: diagnosisId,
patient_id: diag.patient_id,
patient_name: diag.patient_name || '',
gender_desc: formatGender(diag.gender),
age: diag.age ?? 0
}
} catch (e: any) {
feedback.msgWarning(e?.msg || e?.message || '加载诊单失败,无法打开挂号')
}
}
const watchCallVisible = ref(false)
const watchCallDiagnosisId = ref(0)
@@ -1614,13 +1783,13 @@ const handleConfirmAssign = async () => {
feedback.msgWarning('请选择医助')
return
}
assignLoading.value = true
try {
if (assignMode.value === 'single') {
// 单独指派
await tcmDiagnosisAssign({
id: currentRow.value.id,
await tcmDiagnosisAssign({
id: currentRow.value.id,
assistant_id: assignForm.value.assistant_id,
is_inherit: assignForm.value.is_inherit ? 1 : 0
})
@@ -2220,22 +2389,57 @@ const goReadonly = (row: any) => {
}
onMounted(() => {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
getDictOptions()
getLists()
scheduleDeferredDateCounts()
if (String(route.query.revisit_state ?? '') === 'due') {
// 复诊到期弹窗「查看全部」深链:直接激活「复诊到期」chip(内部会 getLists + fetchDateCounts)
void handleRevisitDueTabClick()
} else {
// 默认展示当天挂号,突出重点
formData.appointment_date = todayStr.value
getLists()
scheduleDeferredDateCounts()
}
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
const raw = route.query.id
if (raw != null && raw !== '') {
const num = Number(raw)
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
if (!Number.isNaN(num) && num > 0) {
if (String(route.query.action ?? '') === 'appointment') {
// 复诊到期弹窗「去挂号」深链:等首屏列表回来后再找行,找不到走详情接口
void getListsPromiseForRoute().then(() => openAppointmentFromRoute(num))
} else {
pendingRouteDiagnosisId.value = num
}
}
}
})
/** onMounted 里 getLists() 已发起;等待首屏列表 loading 结束(最多 8s)再找行,失败/超时不阻断 */
const getListsPromiseForRoute = () =>
new Promise<void>((resolve) => {
if (!pager.loading) {
resolve()
return
}
let settled = false
const finish = () => {
if (settled) return
settled = true
stop()
resolve()
}
const stop = watch(
() => pager.loading,
(loading) => {
if (!loading) finish()
}
)
setTimeout(finish, 8000)
})
onUnmounted(() => {
if (silentListPollTimer != null) {
clearInterval(silentListPollTimer)
@@ -2313,6 +2517,21 @@ onUnmounted(() => {
}
}
.chip-revisit-due {
margin-left: 8px;
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
&:hover {
background: var(--el-color-danger-light-8);
}
&.active {
color: #fff;
background: var(--el-color-danger);
}
}
.chip-completed-visit {
margin-left: 8px;
color: var(--el-color-success);
@@ -2451,6 +2670,14 @@ onUnmounted(() => {
.latest-appointment-channel {
width: 170px;
}
.revisit-state-select {
width: 170px;
}
.revisit-days-bucket-select {
width: 130px;
}
}
.list-card {
@@ -2641,17 +2868,6 @@ onUnmounted(() => {
font-weight: 600;
}
.status-prescribed {
font-size: 13px;
color: #4A5DFF;
font-weight: 500;
}
.status-unprescribed {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
/* 未服务天数(T3):null=灰;>=7=红;3-6=橙;<=2=绿 */
.unserved-days {
display: inline-block;
@@ -2696,33 +2912,6 @@ onUnmounted(() => {
}
}
.followup-cell {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.35;
.followup-time {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.followup-doctor {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.followup-void-tag {
align-self: flex-start;
}
}
.followup-none {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.assistant-cell {
font-size: 13px;
color: var(--el-text-color-secondary);
@@ -2782,6 +2971,22 @@ onUnmounted(() => {
:deep(.el-table__row.row-no-appointment:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-info-rgb), 0.08) 0%, transparent 100%) !important;
}
/* 复诊到期未挂号 / 过号未面诊:红色左边条(同 row-unconfirmed 结构,warning → danger) */
:deep(.el-table__row.row-revisit-overdue) {
border-left: 3px solid var(--el-color-danger);
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.06) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
/* 超期 ≥ severe_overdue_days:底色加深到 10%,悬停 14% */
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep) {
border-left-color: #b91c1c;
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.1) 0%, transparent 100%) !important;
}
:deep(.el-table__row.row-revisit-overdue.row-revisit-overdue-deep:hover > td) {
background: linear-gradient(90deg, rgba(var(--el-color-danger-rgb), 0.14) 0%, transparent 100%) !important;
}
}
.record-list {