添加接口

- 体检客户列表
- 体检进度详情
- 客户详情
This commit is contained in:
xianyi
2025-12-10 17:28:15 +08:00
parent f4d5c085ee
commit 0efc4c186e
5 changed files with 428 additions and 71 deletions

View File

@@ -6,16 +6,16 @@ import type {
RevenueStatisticsResponse,
InputTodayExamProgress,
TodayExamProgressResponse,
InputPhysicalExamCustomerList,
PhysicalExamCustomerListResponse,
InputPhysicalExamProgressDetail,
PhysicalExamProgressDetailResponse,
InputCustomerDetail,
CustomerDetailResponse,
} from './types';
/**
* 自助机HIS接口
* 基础路径: /api/his-web/self-service-machine/
*/
const HIS_BASE_PATH = '/api/his-web/self-service-machine';
/**
* 体检中心HIS接口
* 基础路径: /api/his-web/medical-exam-center-app/
*/
const MEDICAL_EXAM_BASE_PATH = '/api/his-web/medical-exam-center-app';
@@ -62,3 +62,39 @@ export const getTodayExamProgress = (
).then(res => res.data);
};
/**
* 体检客户列表
*/
export const getPhysicalExamCustomerList = (
data: InputPhysicalExamCustomerList
): Promise<PhysicalExamCustomerListResponse> => {
return request.post<PhysicalExamCustomerListResponse>(
`${MEDICAL_EXAM_BASE_PATH}/customer-list`,
data
).then(res => res.data);
};
/**
* 体检进度详情
*/
export const getPhysicalExamProgressDetail = (
data: InputPhysicalExamProgressDetail
): Promise<PhysicalExamProgressDetailResponse> => {
return request.post<PhysicalExamProgressDetailResponse>(
`${MEDICAL_EXAM_BASE_PATH}/progress`,
data
).then(res => res.data);
};
/**
* 客户详情
*/
export const getCustomerDetail = (
data: InputCustomerDetail
): Promise<CustomerDetailResponse> => {
return request.post<CustomerDetailResponse>(
`${MEDICAL_EXAM_BASE_PATH}/customer-detail`,
data
).then(res => res.data);
};

View File

@@ -111,3 +111,143 @@ export interface OutputRevenueStatisticsInfo {
*/
export type RevenueStatisticsResponse = CommonActionResult<OutputRevenueStatisticsInfo>;
/**
* 体检客户列表入参
*/
export interface InputPhysicalExamCustomerList {
/** 客户姓名 */
customer_name?: string | null;
/** 联系电话 */
phone?: string | null;
/** 身份证号 */
id_no?: string | null;
/**
* 筛选类型
* 1-全部 2-上午 3-下午 4-高客 5-普客 6-已登记 7-未登记 8-散客 9-团客
*/
filter_type?: number | null;
}
/**
* 体检客户列表项
*/
export interface OutputPhysicalExamCustomerListItem {
physical_exam_status?: number | null;
physical_exam_status_name?: string | null;
physical_exam_id?: number | null;
customer_name?: string | null;
phone?: string | null;
id_no?: string | null;
family_doctor_name?: string | null;
member_level?: string | null;
is_vip?: number | null;
package_code?: string | null;
package_name?: string | null;
add_item_flag?: number | null;
add_item_count?: number | null;
channel?: string | null;
physical_exam_time?: string | null;
physical_exam_complete_time?: string | null;
customer_type?: number | null;
is_register?: number | null;
is_sign_in?: number | null;
is_print?: number | null;
appointment_remarks?: string | null;
}
/**
* 体检客户列表接口返回
*/
export type PhysicalExamCustomerListResponse = CommonActionResult<OutputPhysicalExamCustomerListItem[]>;
/**
* 体检进度详情入参
*/
export interface InputPhysicalExamProgressDetail {
/** 体检ID */
physical_exam_id: number;
}
/**
* 体检进度信息
*/
export interface PhysicalExamProgressItem {
department_id?: number | null;
department_name?: string | null;
project_id?: string | null;
project_name?: string | null;
/** 检查状态1-已查 2-弃检 3-未查 4-延期) */
exam_status?: number | null;
exam_status_name?: string | null;
}
/**
* 客户基本信息(进度、详情共用)
*/
export interface CustomerInfo {
customer_name?: string | null;
physical_exam_number?: string | null;
is_vip?: number | null;
phone?: string | null;
id_no?: string | null;
gender_code?: number | null;
gender_name?: string | null;
patient_marital_status?: number | null;
patient_marital_status_name?: string | null;
family_doctor_name?: string | null;
customer_type?: number | null;
}
/**
* 体检进度详情出参
*/
export interface OutputPhysicalExamProgressDetail {
customerInfo?: CustomerInfo | null;
examProgressesList?: PhysicalExamProgressItem[] | null;
}
/**
* 体检进度详情接口返回
*/
export type PhysicalExamProgressDetailResponse = CommonActionResult<OutputPhysicalExamProgressDetail>;
/**
* 客户详情入参
*/
export interface InputCustomerDetail {
/** 体检ID */
physical_exam_id: number;
}
/**
* 客户预约信息
*/
export interface CustomerAppointmentInfo {
appointment_time?: string | null;
sign_in_time?: string | null;
physical_exam_complete_time?: string | null;
package_name?: string | null;
}
/**
* 客户体检加项
*/
export interface CustomerExamAddItem {
dept_name?: string | null;
combination_name?: string | null;
}
/**
* 客户详情出参
*/
export interface OutputCustomerDetail {
customerInfo?: CustomerInfo | null;
appointmentInfo?: CustomerAppointmentInfo | null;
addItemInfoList?: CustomerExamAddItem[] | null;
}
/**
* 客户详情接口返回
*/
export type CustomerDetailResponse = CommonActionResult<OutputCustomerDetail>;

View File

@@ -1,6 +1,13 @@
import { useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ExamClient, ExamModalTab } from '../../data/mockData';
import type {
CustomerAppointmentInfo,
CustomerExamAddItem,
CustomerInfo,
PhysicalExamProgressItem,
} from '../../api';
import { getCustomerDetail, getPhysicalExamProgressDetail } from '../../api';
import { Button, Input } from '../ui';
interface ExamModalProps {
@@ -43,6 +50,32 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
}
};
const [detailLoading, setDetailLoading] = useState(false);
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
const [appointmentInfo, setAppointmentInfo] = useState<CustomerAppointmentInfo | null>(null);
const [addItemInfoList, setAddItemInfoList] = useState<CustomerExamAddItem[] | null>(null);
const [progressList, setProgressList] = useState<PhysicalExamProgressItem[] | null>(null);
useEffect(() => {
const physical_exam_id = Number(client.id);
if (!physical_exam_id) return;
setDetailLoading(true);
Promise.all([
getCustomerDetail({ physical_exam_id }),
getPhysicalExamProgressDetail({ physical_exam_id }),
])
.then(([detailRes, progressRes]) => {
setCustomerInfo(detailRes.Data?.customerInfo ?? null);
setAppointmentInfo(detailRes.Data?.appointmentInfo ?? null);
setAddItemInfoList(detailRes.Data?.addItemInfoList ?? null);
setProgressList(progressRes.Data?.examProgressesList ?? null);
})
.catch((err) => {
console.error('获取客户详情/进度失败', err);
})
.finally(() => setDetailLoading(false));
}, [client.id]);
return (
<div
className='fixed inset-0 z-40 flex items-center justify-center bg-black/50'
@@ -121,7 +154,16 @@ export const ExamModal = ({ client, tab, onTabChange, onClose }: ExamModalProps)
</div>
<div className='px-4 py-4 bg-gray-50/60'>
{tab === 'detail' && <ExamDetailInfo client={client} />}
{tab === 'detail' && (
<ExamDetailInfo
client={client}
customerInfo={customerInfo}
appointmentInfo={appointmentInfo}
addItemInfoList={addItemInfoList}
progressList={progressList}
loading={detailLoading}
/>
)}
{tab === 'sign' && <ExamSignPanel />}
{tab === 'addon' && <ExamAddonPanel client={client} />}
{tab === 'print' && <ExamPrintPanel client={client} />}
@@ -364,20 +406,64 @@ const ExamAddonPanel = ({ client }: { client: ExamClient }) => {
);
};
const ExamDetailInfo = ({ client }: { client: ExamClient }) => {
const [phone, setPhone] = useState((client['mobile' as keyof ExamClient] as string | undefined) || '137****9988');
const [marital, setMarital] = useState(
(client['maritalStatus' as keyof ExamClient] as string | undefined) || '未婚',
);
const ExamDetailInfo = ({
client,
customerInfo,
appointmentInfo,
addItemInfoList,
progressList,
loading,
}: {
client: ExamClient;
customerInfo: CustomerInfo | null;
appointmentInfo: CustomerAppointmentInfo | null;
addItemInfoList: CustomerExamAddItem[] | null;
progressList: PhysicalExamProgressItem[] | null;
loading: boolean;
}) => {
const basePhone = customerInfo?.phone || (client['mobile' as keyof ExamClient] as string | undefined) || '';
const baseMarital =
customerInfo?.patient_marital_status_name ||
(client['maritalStatus' as keyof ExamClient] as string | undefined) ||
'—';
const [phone, setPhone] = useState(basePhone || '—');
const [marital, setMarital] = useState(baseMarital);
const [phoneEditing, setPhoneEditing] = useState(false);
const [maritalEditing, setMaritalEditing] = useState(false);
const customerChannel = client.customerType === '团客' ? '团体客户' : '散客客户';
const familyDoctor = (client['familyDoctor' as keyof ExamClient] as string | undefined) || '—';
const familyDoctor = customerInfo?.family_doctor_name || (client['familyDoctor' as keyof ExamClient] as string | undefined) || '—';
const groupTag = client['groupTag' as keyof ExamClient] || (client.customerType === '团客' ? '团检' : '—');
const bookingTime = client['bookingTime' as keyof ExamClient] || '—';
const signTime = client['signTime' as keyof ExamClient] || '—';
const addonSummary = client['addonSummary' as keyof ExamClient] || '—';
const bookingTime = appointmentInfo?.appointment_time || (client['bookingTime' as keyof ExamClient] || '—');
const signTime = appointmentInfo?.sign_in_time || (client['signTime' as keyof ExamClient] || '—');
const addonSummary =
addItemInfoList && addItemInfoList.length > 0
? addItemInfoList.map((i) => `${i.dept_name ?? ''} ${i.combination_name ?? ''}`.trim()).join('、')
: client['addonSummary' as keyof ExamClient] || '—';
const progressGroups = useMemo(() => {
const checked: string[] = [];
const abandoned: string[] = [];
const pending: string[] = [];
const deferred: string[] = [];
(progressList || []).forEach((p) => {
const name = p.project_name || p.department_name || '项目';
switch (p.exam_status) {
case 1:
checked.push(name);
break;
case 2:
abandoned.push(name);
break;
case 4:
deferred.push(name);
break;
default:
pending.push(name);
}
});
return { checked, abandoned, pending, deferred };
}, [progressList]);
return (
<div className='space-y-4 text-sm'>
@@ -387,17 +473,19 @@ const ExamDetailInfo = ({ client }: { client: ExamClient }) => {
</div>
</div>
<div className='text-xs text-gray-500'></div>
<div className='text-xs text-gray-500'>
{loading ? '加载中…' : '基础信息:头像、姓名、证件号、手机号等(点击图标可进行编辑)'}
</div>
</div>
<div className='space-y-2 text-xs text-gray-700'>
<div className='font-medium text-gray-900'></div>
<div className='grid grid-cols-2 gap-x-8 gap-y-1'>
<div>
<span className='text-gray-900'>{client.name}</span>
<span className='text-gray-900'>{customerInfo?.customer_name || client.name}</span>
</div>
<div>
<span className='text-gray-900'>4401********1234</span>
<span className='text-gray-900'>{customerInfo?.id_no || '—'}</span>
</div>
<div className='flex items-center'>
<span></span>
@@ -424,7 +512,7 @@ const ExamDetailInfo = ({ client }: { client: ExamClient }) => {
<div>
/
<span className='text-gray-900'>
{client.gender} / {client.age}
{customerInfo?.gender_name || client.gender} / {client.age}
</span>
</div>
<div>
@@ -474,7 +562,12 @@ const ExamDetailInfo = ({ client }: { client: ExamClient }) => {
<span className='text-gray-900'>{signTime as string}</span>
</div>
<div>
<span className='text-gray-900'>{client.elapsed}</span>
<span className='text-gray-900'>
{appointmentInfo?.physical_exam_complete_time && appointmentInfo?.sign_in_time
? `${appointmentInfo.physical_exam_complete_time} - ${appointmentInfo.sign_in_time}`
: client.elapsed}
</span>
</div>
<div className='col-span-2'>
<span className='text-gray-900'>{client.packageName}</span>
@@ -486,36 +579,52 @@ const ExamDetailInfo = ({ client }: { client: ExamClient }) => {
</div>
<div className='grid grid-cols-2 gap-4 text-xs'>
<div className='p-3 rounded-2xl bg-green-50 border'>
<div className='font-medium mb-2'> {client.checkedItems.length} </div>
<div className='p-3 rounded-2xl bg-green-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.checked.length} </div>
<div className='flex flex-wrap gap-2'>
{client.checkedItems.map((i) => (
{(progressGroups.checked.length ? progressGroups.checked : client.checkedItems).map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))}
</div>
</div>
<div className='p-3 rounded-2xl bg-red-50 border'>
<div className='font-medium mb-2'> 0 </div>
<div className='p-3 rounded-2xl bg-red-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.abandoned.length} </div>
<div className='flex flex-wrap gap-2'>
<span className='text-gray-400 text-[11px]'></span>
{progressGroups.abandoned.length ? (
progressGroups.abandoned.map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))
) : (
<span className='text-gray-400 text-[11px]'></span>
)}
</div>
</div>
<div className='p-3 rounded-2xl bg-yellow-50 border'>
<div className='font-medium mb-2'> {client.pendingItems.length} </div>
<div className='p-3 rounded-2xl bg-yellow-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.pending.length} </div>
<div className='flex flex-wrap gap-2'>
{client.pendingItems.map((i) => (
{(progressGroups.pending.length ? progressGroups.pending : client.pendingItems).map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))}
</div>
</div>
<div className='p-3 rounded-2xl bg-blue-50 border'>
<div className='font-medium mb-2'> 0 </div>
<div className='p-3 rounded-2xl bg-blue-50 border max-h-48 overflow-auto custom-scroll'>
<div className='font-medium mb-2'> {progressGroups.deferred.length} </div>
<div className='flex flex-wrap gap-2'>
<span className='text-gray-400 text-[11px]'></span>
{progressGroups.deferred.length ? (
progressGroups.deferred.map((i) => (
<span key={i} className='px-2 py-0.5 rounded-full bg-white border text-[11px]'>
{i}
</span>
))
) : (
<span className='text-gray-400 text-[11px]'></span>
)}
</div>
</div>
</div>

View File

@@ -2,6 +2,26 @@
@tailwind components;
@tailwind utilities;
/* 自定义滚动条(圆角、细滚动条) */
.custom-scroll {
scrollbar-width: none;
scrollbar-color: #cbd5e1 #f8fafc;
}
.custom-scroll::-webkit-scrollbar {
width: 8px;
}
.custom-scroll::-webkit-scrollbar-track {
background: #f8fafc;
border-radius: 9999px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
:root {
font-family: 'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif;
color: #0f172a;

View File

@@ -1,47 +1,97 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import type { ExamClient, ExamModalTab } from '../data/mockData';
import { EXAM_CLIENTS, EXAM_TAGS } from '../data/mockData';
import { EXAM_TAGS } from '../data/mockData';
import { ExamSection } from '../components/exam/ExamSection';
import { ExamModal } from '../components/exam/ExamModal';
import type { MainLayoutContext } from '../layouts/MainLayout';
import { getPhysicalExamCustomerList } from '../api';
export const ExamPage = () => {
const { search } = useOutletContext<MainLayoutContext>();
const [examSelectedId, setExamSelectedId] = useState<string>('A001');
const [clients, setClients] = useState<ExamClient[]>([]);
const [examSelectedId, setExamSelectedId] = useState<string>('');
const [examPanelTab, setExamPanelTab] = useState<ExamModalTab>('detail');
const [examModalOpen, setExamModalOpen] = useState(false);
const [examFilterTag, setExamFilterTag] = useState<(typeof EXAM_TAGS)[number]>('全部');
const filteredClients = useMemo(() => {
return EXAM_CLIENTS.filter((c) => (c.name + c.packageName + c.id).toLowerCase().includes(search.trim().toLowerCase())).filter(
(c) => {
switch (examFilterTag) {
case '上午':
return c.timeSlot === '午';
case '下午':
return c.timeSlot === '下午';
case '高客':
return c.vipType === '客';
case '普客':
return c.vipType === '普客';
case '已登记':
return c.signStatus === '登记';
case '未登记':
return c.signStatus !== '已登记';
case '散客':
return c.customerType === '客';
case '团客':
return c.customerType === '团客';
default:
return true;
}
},
);
}, [search, examFilterTag]);
// 将筛选标签映射为接口 filter_type
const filterType = useMemo(() => {
switch (examFilterTag) {
case '上午':
return 2;
case '午':
return 3;
case '高客':
return 4;
case '客':
return 5;
case '已登记':
return 6;
case '登记':
return 7;
case '散客':
return 8;
case '客':
return 9;
default:
return 1; // 全部
}
}, [examFilterTag]);
const selectedExamClient: ExamClient = EXAM_CLIENTS.find((c) => c.id === examSelectedId) || EXAM_CLIENTS[0];
// 从接口拉取体检客户列表
useEffect(() => {
const payload = {
customer_name: search.trim() || undefined,
phone: undefined,
id_no: undefined,
filter_type: filterType,
};
getPhysicalExamCustomerList(payload)
.then((res) => {
const list = res.Data || [];
const mapped: ExamClient[] = list.map((item) => {
// 简单映射为现有 UI 使用的字段
const statusName = item.physical_exam_status_name || '';
const signStatus = item.is_register === 1 ? '已登记' : '未登记';
const customerType = item.customer_type === 1 ? '团客' : '散客';
const vipType = item.is_vip === 1 ? '高客' : '普客';
return {
id: String(item.physical_exam_id ?? ''),
name: item.customer_name || '未知客户',
gender: '男', // 后端未提供,默认填充
age: 0,
level: item.member_level || (item.is_vip === 1 ? 'VIP' : '普通'),
packageName: item.package_name || '未提供套餐',
status: statusName || '未开始',
elapsed: '',
checkedItems: [],
pendingItems: [],
timeSlot: '上午',
vipType,
signStatus,
customerType,
guidePrinted: item.is_print === 1,
addonCount: item.add_item_count ?? 0,
};
});
setClients(mapped);
if (mapped.length && !examSelectedId) {
setExamSelectedId(mapped[0].id);
} else if (mapped.length === 0) {
setExamSelectedId('');
}
})
.catch((err) => {
console.error('获取体检客户列表失败', err);
});
}, [search, filterType, examSelectedId]);
const selectedExamClient: ExamClient | undefined = useMemo(
() => clients.find((c) => c.id === examSelectedId) || clients[0],
[clients, examSelectedId],
);
const handleOpenModal = (id: string, tab: ExamModalTab) => {
setExamSelectedId(id);
@@ -51,13 +101,15 @@ export const ExamPage = () => {
return (
<>
<ExamSection
filteredClients={filteredClients}
selectedExamClient={selectedExamClient}
examFilterTag={examFilterTag}
onFilterChange={setExamFilterTag}
onOpenModal={handleOpenModal}
/>
{selectedExamClient && (
<ExamSection
filteredClients={clients}
selectedExamClient={selectedExamClient}
examFilterTag={examFilterTag}
onFilterChange={setExamFilterTag}
onOpenModal={handleOpenModal}
/>
)}
{examModalOpen && (
<ExamModal