From e79f3190b837e304d2e6d9429a0c63db0d7be490 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Sat, 6 Jun 2026 15:58:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=82=A3=E8=80=85?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=8C=89=E6=9C=80=E8=BF=91=E6=8C=82=E5=8F=B7?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tcm.diagnosis/lists 新增最近有效挂号时间区间与渠道筛选,lists/count 共用过滤逻辑 - 诊单患者列表更多筛选增加最近挂号时间和渠道,并与顶部挂号快捷筛选互斥 - 明确 daterange-picker change 事件转发,列表补充最近挂号渠道摘要展示 --- .../src/components/daterange-picker/index.vue | 3 +- admin/src/views/tcm/diagnosis/index.vue | 116 +++++++++- .../app/adminapi/lists/tcm/DiagnosisLists.php | 212 ++++++++++++++++++ 3 files changed, 329 insertions(+), 2 deletions(-) diff --git a/admin/src/components/daterange-picker/index.vue b/admin/src/components/daterange-picker/index.vue index a2146d1e..9f2b9b90 100644 --- a/admin/src/components/daterange-picker/index.vue +++ b/admin/src/components/daterange-picker/index.vue @@ -7,6 +7,7 @@ :end-placeholder="endPlaceholder" :value-format="valueFormat" clearable + @change="emit('change', $event)" > @@ -29,7 +30,7 @@ const props = withDefaults( endPlaceholder: '结束时间' } ) -const emit = defineEmits(['update:startTime', 'update:endTime']) +const emit = defineEmits(['update:startTime', 'update:endTime', 'change']) const content = computed({ get: () => { diff --git a/admin/src/views/tcm/diagnosis/index.vue b/admin/src/views/tcm/diagnosis/index.vue index 498269c3..d0ed87b5 100644 --- a/admin/src/views/tcm/diagnosis/index.vue +++ b/admin/src/views/tcm/diagnosis/index.vue @@ -14,6 +14,7 @@ formData.pending_assign !== '1' && formData.pending_booking !== '1' && formData.completed_appointment !== '1' && + !hasLatestAppointmentFilter() && formData.appointment_date === tab.value } ]" @@ -110,6 +111,32 @@ + + + + 重置 @@ -213,6 +240,9 @@
{{ row.appointment_doctor_name || '-' }}
{{ row.appointment_time_text || '-' }}
+
+ 最近渠道:{{ latestAppointmentChannelText(row) }} +
未挂号 @@ -689,6 +719,7 @@ import useUserStore from '@/stores/modules/user' 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' const EditPopup = defineAsyncComponent(() => import('./edit.vue')) const DetailPopup = defineAsyncComponent(() => import('./detail.vue')) @@ -728,6 +759,9 @@ const formData = reactive({ assistant_id: '', start_time: '', end_time: '', + latest_appointment_start_date: '' as string, + latest_appointment_end_date: '' as string, + latest_appointment_channel_source: '' as string, diagnosis_confirmed: '' as '' | '0' | '1', appointment_date: '' as string, has_appointment: '' as '' | '0' | '1', @@ -760,6 +794,9 @@ const setHasAppointment = (v: string) => { formData.has_appointment = v as '' | '0' | '1' formData.pending_booking = '' formData.completed_appointment = '' + if (v === '0') { + clearLatestAppointmentFilters() + } pager.page = 1 getLists() fetchDateCounts() @@ -823,6 +860,9 @@ function buildPendingAssignCountPayload(): Record { has_appointment: '', pending_booking: '', completed_appointment: '', + latest_appointment_start_date: '', + latest_appointment_end_date: '', + latest_appointment_channel_source: '', pending_assign_order_month: resolvePendingAssignOrderMonthForRequest() } as Record) as Record } @@ -850,6 +890,9 @@ function clearSecondaryFiltersWhenPendingAssignWideSearch() { formData.assistant_id = '' formData.start_time = '' formData.end_time = '' + formData.latest_appointment_start_date = '' + formData.latest_appointment_end_date = '' + formData.latest_appointment_channel_source = '' formData.pending_assign_order_month = '' activeTab.value = 'all' if (kw1 !== '') { @@ -886,6 +929,7 @@ const handleDateTabClick = async (value: string) => { formData.pending_booking = '' formData.completed_appointment = '' formData.has_appointment = '' + clearLatestAppointmentFilters() formData.appointment_date = value pager.page = 1 await getLists() @@ -901,6 +945,7 @@ const handlePendingBookingTabClick = async () => { formData.completed_appointment = '' formData.appointment_date = '' formData.has_appointment = '0' + clearLatestAppointmentFilters() pager.page = 1 await getLists() fetchDateCounts() @@ -915,6 +960,7 @@ const handleCompletedVisitTabClick = async () => { formData.completed_appointment = '1' formData.has_appointment = '' formData.appointment_date = '' + clearLatestAppointmentFilters() pager.page = 1 await getLists() fetchDateCounts() @@ -926,6 +972,7 @@ const handlePendingAssignTabClick = async () => { formData.completed_appointment = '' formData.has_appointment = '' formData.appointment_date = '' + clearLatestAppointmentFilters() if (!formData.pending_assign_order_month) { formData.pending_assign_order_month = dayjs().format('YYYY-MM') } @@ -1047,16 +1094,29 @@ const handleMoreCommand = (cmd: string) => { const diagnosisTypeOptions = ref([]) const syndromeTypeOptions = ref([]) const assistantOptions = ref([]) +const channelOptions = ref>([]) const getDictOptions = async () => { try { - const [diagnosisType, syndromeType, assistants] = await Promise.all([ + const [diagnosisType, syndromeType, channels, assistants] = await Promise.all([ getDictData({ type: 'diagnosis_type' }), getDictData({ type: 'syndrome_type' }), + getDictData({ type: 'channels' }), getAssistants() ]) diagnosisTypeOptions.value = diagnosisType?.diagnosis_type || [] syndromeTypeOptions.value = syndromeType?.syndrome_type || [] + channelOptions.value = (channels?.channels || []) + .filter((row: any) => row.status !== 0) + .sort((a: any, b: any) => { + const ds = Number(b?.sort ?? 0) - Number(a?.sort ?? 0) + if (ds !== 0) return ds + return Number(b?.id ?? 0) - Number(a?.id ?? 0) + }) + .map((row: any) => ({ + name: String(row?.name ?? row?.value ?? ''), + value: row?.value != null && row.value !== '' ? String(row.value) : '' + })) assistantOptions.value = assistants || [] } catch (error) { console.error('获取字典数据失败:', error) @@ -1069,6 +1129,40 @@ const getDictLabel = (options: any[], value: string) => { return item ? item.name : value || '-' } +const clearLatestAppointmentFilters = () => { + formData.latest_appointment_start_date = '' + formData.latest_appointment_end_date = '' + formData.latest_appointment_channel_source = '' +} + +const hasLatestAppointmentFilter = () => + !!( + formData.latest_appointment_start_date || + formData.latest_appointment_end_date || + formData.latest_appointment_channel_source + ) + +const handleLatestAppointmentFilterChange = () => { + if (hasLatestAppointmentFilter()) { + formData.appointment_date = '' + formData.pending_booking = '' + formData.completed_appointment = '' + if (formData.has_appointment === '0') { + formData.has_appointment = '' + } + } + doSearch() +} + +const latestAppointmentChannelText = (row: any) => { + const desc = String(row?.latest_appointment_channel_source_desc || '').trim() + const raw = String(row?.latest_appointment_channel_source || '').trim() + const detail = String(row?.latest_appointment_channel_source_detail || '').trim() + const base = desc || raw + if (!base) return '' + return detail ? `${base}(${detail})` : base +} + // 获取医助名称 const getAssistantName = (assistantId: number | string) => { // 如果没有assistant_id,返回"-" @@ -1103,6 +1197,9 @@ const handleReset = () => { formData.diagnosis_type = '' formData.syndrome_type = '' formData.assistant_id = '' + formData.latest_appointment_start_date = '' + formData.latest_appointment_end_date = '' + formData.latest_appointment_channel_source = '' formData.diagnosis_confirmed = '' formData.appointment_date = '' formData.has_appointment = '' @@ -2190,10 +2287,20 @@ onUnmounted(() => { .filter-more { display: flex; align-items: center; + flex-wrap: wrap; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--el-border-color-lighter); + + .latest-appointment-range { + width: 260px; + max-width: 100%; + } + + .latest-appointment-channel { + width: 170px; + } } .list-card { @@ -2363,6 +2470,13 @@ onUnmounted(() => { font-size: 13px; color: var(--el-text-color-placeholder); } + + .apt-latest-channel { + margin-top: 6px; + font-size: 12px; + line-height: 1.35; + color: var(--el-text-color-secondary); + } } .status-confirmed { diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php index f9ee093c..f94b9ec2 100755 --- a/server/app/adminapi/lists/tcm/DiagnosisLists.php +++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php @@ -28,8 +28,10 @@ use app\common\model\tcm\CallRecord; use app\common\model\auth\Admin; use app\common\model\auth\AdminDept; use app\common\model\auth\AdminRole; +use app\common\model\dict\DictData; use app\common\lists\ListsSearchInterface; use app\common\lists\Traits\HasDataScopeFilter; +use think\facade\Db; /** * 中医辨房病因诊单列表 @@ -39,6 +41,10 @@ use app\common\lists\Traits\HasDataScopeFilter; class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface { use HasDataScopeFilter; + + /** 最近挂号筛选只统计这些有效状态:已预约、已完成、已过号 */ + private const EFFECTIVE_APPOINTMENT_STATUSES = [1, 3, 4]; + /** * @notes 设置搜索条件 * @return array @@ -133,6 +139,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface $query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3"); } + $this->applyLatestAppointmentFilters($query, $pendingWideSearch); + $this->applyPendingAssignBusinessOrderMonthFilter($query); // 仅已开方(待分配+关键词检索时不限制) @@ -210,6 +218,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface ->order('appointment_time', 'asc') ->order('id', 'asc'); $appointments = $subQuery->select()->toArray(); + $latestAppointmentMap = $this->buildLatestAppointmentMap($diagnosisIds); foreach ($appointments as $apt) { $did = (int) ($apt['patient_id'] ?? 0); @@ -240,6 +249,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface } // 合并到诊单列表 foreach ($lists as &$item) { + $latestAppointment = $latestAppointmentMap[(int) $item['id']] ?? null; + $this->appendLatestAppointmentSummary($item, $latestAppointment); $aptList = $appointmentMap[(int) $item['id']] ?? []; if (!empty($aptList)) { $apt = $aptList[0]; @@ -280,6 +291,7 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface } } else { foreach ($lists as &$item) { + $this->appendLatestAppointmentSummary($item, null); $item['has_appointment'] = 0; $item['appointment_id'] = null; $item['appointment_status'] = 0; @@ -558,6 +570,8 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface $query->whereExists("SELECT 1 FROM {$aptTbl} apt WHERE apt.patient_id = {$diagTbl}.id AND apt.status = 3"); } + $this->applyLatestAppointmentFilters($query, $pendingWideSearch); + // 仅已开方(待分配+关键词检索时不限制) if (!$pendingWideSearch && isset($this->params['only_has_prescription']) && (string) $this->params['only_has_prescription'] === '1') { $rxTbl = (new Prescription())->getTable(); @@ -587,6 +601,204 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface return $query->count(); } + /** + * 最近一次有效挂号过滤:按 appointment_date DESC, appointment_time DESC, id DESC 取一条。 + * + * @param mixed $query + */ + private function applyLatestAppointmentFilters($query, bool $pendingWideSearch): void + { + if ($pendingWideSearch) { + return; + } + + $startDate = $this->normalizeYmd($this->params['latest_appointment_start_date'] ?? ''); + $endDate = $this->normalizeYmd($this->params['latest_appointment_end_date'] ?? ''); + $channelSource = trim((string) ($this->params['latest_appointment_channel_source'] ?? '')); + + if ($startDate === '' && $endDate === '' && $channelSource === '') { + return; + } + + $aptTbl = (new Appointment())->getTable(); + $diagTbl = (new Diagnosis())->getTable(); + $latestIdSql = $this->latestAppointmentIdSubSql($aptTbl, $diagTbl); + $conditions = ["latest_apt.id = ({$latestIdSql})"]; + + if ($startDate !== '') { + $conditions[] = "latest_apt.appointment_date >= '" . addslashes($startDate) . "'"; + } + if ($endDate !== '') { + $conditions[] = "latest_apt.appointment_date <= '" . addslashes($endDate) . "'"; + } + if ($channelSource !== '') { + $channelCond = $this->appointmentChannelConditionSql('latest_apt', $channelSource); + if ($channelCond === '') { + $query->whereRaw('0 = 1'); + + return; + } + $conditions[] = $channelCond; + } + + $query->whereExists("SELECT 1 FROM {$aptTbl} latest_apt WHERE " . implode(' AND ', $conditions)); + } + + private function latestAppointmentIdSubSql(string $aptTbl, string $diagTbl): string + { + $statuses = implode(',', self::EFFECTIVE_APPOINTMENT_STATUSES); + + return "SELECT apt_latest.id FROM {$aptTbl} apt_latest " + . "WHERE apt_latest.patient_id = {$diagTbl}.id " + . "AND apt_latest.status IN ({$statuses}) " + . "ORDER BY apt_latest.appointment_date DESC, " + . "IFNULL(NULLIF(TRIM(apt_latest.appointment_time), ''), '00:00:00') DESC, " + . "apt_latest.id DESC LIMIT 1"; + } + + private function appointmentChannelConditionSql(string $alias, string $channelSource): string + { + $cols = $this->appointmentTableFields(); + $hasChannelSource = in_array('channel_source', $cols, true); + $hasChannels = in_array('channels', $cols, true); + if (!$hasChannelSource && !$hasChannels) { + return ''; + } + + $quoted = "'" . addslashes($channelSource) . "'"; + $parts = []; + if ($hasChannelSource) { + $parts[] = "{$alias}.channel_source = {$quoted}"; + } + if ($hasChannels) { + $parts[] = "{$alias}.channels = {$quoted}"; + if (is_numeric($channelSource)) { + $parts[] = "{$alias}.channels = " . (int) $channelSource; + } + } + + $parts = array_values(array_unique($parts)); + + return $parts === [] ? '' : '(' . implode(' OR ', $parts) . ')'; + } + + /** + * @param array $diagnosisIds + * @return array> + */ + private function buildLatestAppointmentMap(array $diagnosisIds): array + { + $diagnosisIds = array_values(array_filter(array_unique(array_map('intval', $diagnosisIds)), static function (int $id): bool { + return $id > 0; + })); + if ($diagnosisIds === []) { + return []; + } + + $cols = $this->appointmentTableFields(); + $fields = ['id', 'patient_id', 'appointment_date', 'appointment_time', 'status']; + foreach (['channel_source', 'channel_source_detail', 'channels'] as $col) { + if (in_array($col, $cols, true)) { + $fields[] = $col; + } + } + + $appointments = Appointment::whereIn('patient_id', $diagnosisIds) + ->whereIn('status', self::EFFECTIVE_APPOINTMENT_STATUSES) + ->field($fields) + ->order('patient_id', 'asc') + ->order('appointment_date', 'desc') + ->orderRaw("IFNULL(NULLIF(TRIM(appointment_time), ''), '00:00:00') DESC") + ->order('id', 'desc') + ->select() + ->toArray(); + + $map = []; + $channelNameByValue = DictData::where('type_value', 'channels')->column('name', 'value'); + foreach ($appointments as $appointment) { + $diagnosisId = (int) ($appointment['patient_id'] ?? 0); + if ($diagnosisId <= 0 || isset($map[$diagnosisId])) { + continue; + } + $appointment['channel_source_desc'] = $this->appointmentChannelDesc($appointment, $channelNameByValue ?: []); + $map[$diagnosisId] = $appointment; + } + + return $map; + } + + /** + * @param array $item + * @param array|null $appointment + */ + private function appendLatestAppointmentSummary(array &$item, ?array $appointment): void + { + $item['latest_appointment_id'] = null; + $item['latest_appointment_time_text'] = ''; + $item['latest_appointment_channel_source'] = ''; + $item['latest_appointment_channel_source_desc'] = ''; + $item['latest_appointment_channel_source_detail'] = ''; + + if ($appointment === null) { + return; + } + + $timePart = (string) ($appointment['appointment_time'] ?? ''); + if (strlen($timePart) > 5) { + $timePart = substr($timePart, 0, 5); + } + $rawChannel = trim((string) ($appointment['channel_source'] ?? '')); + if ($rawChannel === '' && isset($appointment['channels']) && $appointment['channels'] !== '' && $appointment['channels'] !== null) { + $rawChannel = trim((string) $appointment['channels']); + } + + $item['latest_appointment_id'] = (int) ($appointment['id'] ?? 0); + $item['latest_appointment_time_text'] = trim((string) ($appointment['appointment_date'] ?? '') . ' ' . $timePart); + $item['latest_appointment_channel_source'] = $rawChannel; + $item['latest_appointment_channel_source_desc'] = (string) ($appointment['channel_source_desc'] ?? ''); + $item['latest_appointment_channel_source_detail'] = trim((string) ($appointment['channel_source_detail'] ?? '')); + } + + /** + * @param array $appointment + * @param array $channelNameByValue + */ + private function appointmentChannelDesc(array $appointment, array $channelNameByValue): string + { + $srcKey = trim((string) ($appointment['channel_source'] ?? '')); + if ($srcKey === '' && isset($appointment['channels']) && $appointment['channels'] !== '' && $appointment['channels'] !== null) { + $srcKey = trim((string) $appointment['channels']); + } + if ($srcKey === '') { + return ''; + } + + return (string) ($channelNameByValue[$srcKey] ?? $channelNameByValue[(string) (int) $srcKey] ?? $srcKey); + } + + /** + * @return string[] + */ + private function appointmentTableFields(): array + { + static $fields = null; + if ($fields !== null) { + return $fields; + } + + $tblFields = Db::name('doctor_appointment')->getTableFields(); + $fields = is_array($tblFields) ? array_values(array_map('strval', $tblFields)) : []; + + return $fields; + } + + private function normalizeYmd($value): string + { + $date = trim((string) $value); + + return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) ? $date : ''; + } + /** * 待分配 Tab 且有关键词时:放宽列表条件(与 `applyDiagnosisListKeywordFilter` 判定一致)。 */