分离体检加项面板
This commit is contained in:
214
src/components/exam/ExamAddonPanel.tsx
Normal file
214
src/components/exam/ExamAddonPanel.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ExamClient } from '../../data/mockData';
|
||||
import { Button, Input } from '../ui';
|
||||
|
||||
interface AddonTag {
|
||||
title: string;
|
||||
type: 1 | 2 | 3 | 4; // 1: 热门(红), 2: 普通(灰), 3: 医生推荐(蓝), 4: 折扣信息
|
||||
}
|
||||
|
||||
interface AddonItem {
|
||||
id?: string;
|
||||
name: string;
|
||||
paid?: boolean;
|
||||
tags?: AddonTag[];
|
||||
originalPrice?: string;
|
||||
currentPrice?: string;
|
||||
price?: number; // 兼容 addonOptions 结构
|
||||
}
|
||||
|
||||
export const ExamAddonPanel = ({ client }: { client: ExamClient }) => {
|
||||
// 从 client 获取加项选项数据
|
||||
const addonOptions = (client['addonOptions' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||||
const addonSummary = (client['addonSummary' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||||
|
||||
// 合并数据,优先使用 addonOptions,如果没有则使用 addonSummary
|
||||
const allAddons: AddonItem[] = addonOptions.length > 0
|
||||
? addonOptions.map(item => ({
|
||||
id: item.id || `addon_${item.name}`,
|
||||
name: item.name,
|
||||
paid: item.paid || false,
|
||||
tags: item.tags || [],
|
||||
originalPrice: item.originalPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||||
currentPrice: item.currentPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||||
}))
|
||||
: addonSummary;
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
new Set(allAddons.filter(item => item.paid).map(item => item.id || item.name))
|
||||
);
|
||||
|
||||
const maxSelect = 15;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
const [addonSearch, setAddonSearch] = useState('');
|
||||
|
||||
const filteredAddons = addonSearch.trim()
|
||||
? allAddons.filter(item =>
|
||||
item.name.toLowerCase().includes(addonSearch.toLowerCase())
|
||||
)
|
||||
: allAddons;
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.has(id)) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
if (selectedCount < maxSelect) {
|
||||
setSelectedIds(prev => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 计算价格汇总
|
||||
const selectedItems = allAddons.filter(item => selectedIds.has(item.id || item.name));
|
||||
const totalOriginal = selectedItems.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.originalPrice || item.currentPrice || '0');
|
||||
}, 0);
|
||||
const totalCurrent = selectedItems.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.currentPrice || item.originalPrice || '0');
|
||||
}, 0);
|
||||
const discount = totalOriginal - totalCurrent;
|
||||
|
||||
// 获取标签样式
|
||||
const getTagStyle = (tag: AddonTag) => {
|
||||
switch (tag.type) {
|
||||
case 1: // 热门
|
||||
return 'bg-[#FDF0F0] text-[#BC4845]';
|
||||
case 3: // 医生推荐
|
||||
return 'bg-[#ECF0FF] text-[#6A6AE5]';
|
||||
case 4: // 折扣信息
|
||||
return 'bg-[#4C5460] text-[#F1F2F5]';
|
||||
default: // 2: 普通
|
||||
return 'bg-[#F1F2F5] text-[#464E5B]';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取折扣信息文字(从 tags 中提取 type 4 的标签,或计算折扣)
|
||||
const getDiscountText = (item: AddonItem) => {
|
||||
const discountTag = item.tags?.find(t => t.type === 4);
|
||||
if (discountTag) return discountTag.title;
|
||||
|
||||
const orig = parseFloat(item.originalPrice || '0');
|
||||
const curr = parseFloat(item.currentPrice || '0');
|
||||
if (orig > 0 && curr < orig) {
|
||||
const percent = Math.round((curr / orig) * 100);
|
||||
return `渠道 ${percent} 折`;
|
||||
}
|
||||
return '渠道价';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 标题和说明 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-2 gap-3'>
|
||||
<h3 className='text-lg font-semibold text-gray-900'>体检套餐加项选择</h3>
|
||||
<div className='w-[260px]'>
|
||||
<Input
|
||||
placeholder='搜索 加项名称'
|
||||
value={addonSearch}
|
||||
onChange={(e) => setAddonSearch(e.target.value)}
|
||||
className='text-sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-xs text-gray-600 space-y-1'>
|
||||
<div>最多可选 {maxSelect} 项 · 一排 5 个</div>
|
||||
<div>已勾选 {selectedCount} 项,自费加项费用按渠道折扣价结算。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加项网格 */}
|
||||
<div className='grid grid-cols-5 gap-3 min-h-[142px]'>
|
||||
{filteredAddons.map((item) => {
|
||||
const id = item.id || item.name;
|
||||
const isSelected = selectedIds.has(id);
|
||||
const origPrice = parseFloat(item.originalPrice || '0');
|
||||
const currPrice = parseFloat(item.currentPrice || '0');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-all'
|
||||
}`}
|
||||
onClick={() => toggleSelect(id)}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<div className='flex items-start gap-2 mb-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* 项目名称 */}
|
||||
<div className='font-semibold text-[14px] text-gray-900 mb-1'>{item.name}</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-1 mb-2'>
|
||||
{item.tags
|
||||
.filter(t => t.type !== 4) // 折扣信息单独显示
|
||||
.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`text-[10px] px-2 rounded-full ${getTagStyle(tag)}`}
|
||||
>
|
||||
{tag.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 价格信息 */}
|
||||
<div className='mt-2'>
|
||||
<div className='flex flex-col'>
|
||||
{origPrice > 0 && origPrice > currPrice && (
|
||||
<span className='text-xs text-gray-400 line-through'>¥{origPrice.toFixed(0)}</span>
|
||||
)}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[14px] font-bold text-red-600'>¥{currPrice.toFixed(0)}</span>
|
||||
<span className={`text-[10px] px-2 rounded-full bg-[#EAFCF1] text-[#447955] whitespace-nowrap`}>{getDiscountText(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部汇总和支付 */}
|
||||
<div className='border-t pt-4 mt-6 flex items-center justify-between'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<div className='text-gray-600'>
|
||||
加项原价合计: <span className='text-gray-900'>¥{totalOriginal.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className='text-gray-600'>
|
||||
渠道折扣价: <span className='text-xl font-bold text-red-600'>¥{totalCurrent.toFixed(0)}</span>
|
||||
{discount > 0 && (
|
||||
<span className='text-gray-500 ml-1'>已优惠 ¥{discount.toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500'>结算方式: 个人支付 (微信 / 支付宝)</div>
|
||||
</div>
|
||||
<Button
|
||||
className='bg-[#269745] hover:bg-[#269745]/80 rounded-3xl text-white px-6 py-3 text-base font-medium'
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
确认支付 ¥{totalCurrent.toFixed(0)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { CustomerAppointmentInfo, CustomerExamAddItem, CustomerInfo, Output
|
||||
import { getCustomerDetail, getPhysicalExamProgressDetail, getTongyishuPdf, signInMedicalExamCenter, submitTongyishuSign } from '../../api';
|
||||
import { Button, Input, SignaturePad, type SignaturePadHandle } from '../ui';
|
||||
import { ExamDetailPanel } from './ExamDetailPanel';
|
||||
import { ExamAddonPanel } from './ExamAddonPanel';
|
||||
|
||||
interface ExamModalProps {
|
||||
client: ExamClient;
|
||||
@@ -558,214 +559,6 @@ const ExamSignPanel = ({ examId }: { examId?: number }) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface AddonTag {
|
||||
title: string;
|
||||
type: 1 | 2 | 3 | 4; // 1: 热门(红), 2: 普通(灰), 3: 医生推荐(蓝), 4: 折扣信息
|
||||
}
|
||||
|
||||
interface AddonItem {
|
||||
id?: string;
|
||||
name: string;
|
||||
paid?: boolean;
|
||||
tags?: AddonTag[];
|
||||
originalPrice?: string;
|
||||
currentPrice?: string;
|
||||
price?: number; // 兼容 addonOptions 结构
|
||||
}
|
||||
|
||||
const ExamAddonPanel = ({ client }: { client: ExamClient }) => {
|
||||
// 从 client 获取加项选项数据
|
||||
const addonOptions = (client['addonOptions' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||||
const addonSummary = (client['addonSummary' as keyof ExamClient] as AddonItem[] | undefined) || [];
|
||||
|
||||
// 合并数据,优先使用 addonOptions,如果没有则使用 addonSummary
|
||||
const allAddons: AddonItem[] = addonOptions.length > 0
|
||||
? addonOptions.map(item => ({
|
||||
id: item.id || `addon_${item.name}`,
|
||||
name: item.name,
|
||||
paid: item.paid || false,
|
||||
tags: item.tags || [],
|
||||
originalPrice: item.originalPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||||
currentPrice: item.currentPrice || (item.price ? item.price.toFixed(2) : '0.00'),
|
||||
}))
|
||||
: addonSummary;
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
new Set(allAddons.filter(item => item.paid).map(item => item.id || item.name))
|
||||
);
|
||||
|
||||
const maxSelect = 15;
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
const [addonSearch, setAddonSearch] = useState('');
|
||||
|
||||
const filteredAddons = addonSearch.trim()
|
||||
? allAddons.filter(item =>
|
||||
item.name.toLowerCase().includes(addonSearch.toLowerCase())
|
||||
)
|
||||
: allAddons;
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
if (selectedIds.has(id)) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
if (selectedCount < maxSelect) {
|
||||
setSelectedIds(prev => new Set(prev).add(id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 计算价格汇总
|
||||
const selectedItems = allAddons.filter(item => selectedIds.has(item.id || item.name));
|
||||
const totalOriginal = selectedItems.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.originalPrice || item.currentPrice || '0');
|
||||
}, 0);
|
||||
const totalCurrent = selectedItems.reduce((sum, item) => {
|
||||
return sum + parseFloat(item.currentPrice || item.originalPrice || '0');
|
||||
}, 0);
|
||||
const discount = totalOriginal - totalCurrent;
|
||||
|
||||
// 获取标签样式
|
||||
const getTagStyle = (tag: AddonTag) => {
|
||||
switch (tag.type) {
|
||||
case 1: // 热门
|
||||
return 'bg-[#FDF0F0] text-[#BC4845]';
|
||||
case 3: // 医生推荐
|
||||
return 'bg-[#ECF0FF] text-[#6A6AE5]';
|
||||
case 4: // 折扣信息
|
||||
return 'bg-[#4C5460] text-[#F1F2F5]';
|
||||
default: // 2: 普通
|
||||
return 'bg-[#F1F2F5] text-[#464E5B]';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取折扣信息文字(从 tags 中提取 type 4 的标签,或计算折扣)
|
||||
const getDiscountText = (item: AddonItem) => {
|
||||
const discountTag = item.tags?.find(t => t.type === 4);
|
||||
if (discountTag) return discountTag.title;
|
||||
|
||||
const orig = parseFloat(item.originalPrice || '0');
|
||||
const curr = parseFloat(item.currentPrice || '0');
|
||||
if (orig > 0 && curr < orig) {
|
||||
const percent = Math.round((curr / orig) * 100);
|
||||
return `渠道 ${percent} 折`;
|
||||
}
|
||||
return '渠道价';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 标题和说明 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between mb-2 gap-3'>
|
||||
<h3 className='text-lg font-semibold text-gray-900'>体检套餐加项选择</h3>
|
||||
<div className='w-[260px]'>
|
||||
<Input
|
||||
placeholder='搜索 加项名称'
|
||||
value={addonSearch}
|
||||
onChange={(e) => setAddonSearch(e.target.value)}
|
||||
className='text-sm'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-xs text-gray-600 space-y-1'>
|
||||
<div>最多可选 {maxSelect} 项 · 一排 5 个</div>
|
||||
<div>已勾选 {selectedCount} 项,自费加项费用按渠道折扣价结算。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加项网格 */}
|
||||
<div className='grid grid-cols-5 gap-3 min-h-[142px]'>
|
||||
{filteredAddons.map((item) => {
|
||||
const id = item.id || item.name;
|
||||
const isSelected = selectedIds.has(id);
|
||||
const origPrice = parseFloat(item.originalPrice || '0');
|
||||
const currPrice = parseFloat(item.currentPrice || '0');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`border rounded-lg p-3 cursor-pointer transition-all'
|
||||
}`}
|
||||
onClick={() => toggleSelect(id)}
|
||||
>
|
||||
{/* 复选框 */}
|
||||
<div className='flex items-start gap-2 mb-2'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='mt-0.5 w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500'
|
||||
/>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{/* 项目名称 */}
|
||||
<div className='font-semibold text-[14px] text-gray-900 mb-1'>{item.name}</div>
|
||||
|
||||
{/* 标签 */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-1 mb-2'>
|
||||
{item.tags
|
||||
.filter(t => t.type !== 4) // 折扣信息单独显示
|
||||
.map((tag, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`text-[10px] px-2 rounded-full ${getTagStyle(tag)}`}
|
||||
>
|
||||
{tag.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 价格信息 */}
|
||||
<div className='mt-2'>
|
||||
<div className='flex flex-col'>
|
||||
{origPrice > 0 && origPrice > currPrice && (
|
||||
<span className='text-xs text-gray-400 line-through'>¥{origPrice.toFixed(0)}</span>
|
||||
)}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<span className='text-[14px] font-bold text-red-600'>¥{currPrice.toFixed(0)}</span>
|
||||
<span className={`text-[10px] px-2 rounded-full bg-[#EAFCF1] text-[#447955] whitespace-nowrap`}>{getDiscountText(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 底部汇总和支付 */}
|
||||
<div className='border-t pt-4 mt-6 flex items-center justify-between'>
|
||||
<div className='space-y-1 text-sm'>
|
||||
<div className='text-gray-600'>
|
||||
加项原价合计: <span className='text-gray-900'>¥{totalOriginal.toFixed(0)}</span>
|
||||
</div>
|
||||
<div className='text-gray-600'>
|
||||
渠道折扣价: <span className='text-xl font-bold text-red-600'>¥{totalCurrent.toFixed(0)}</span>
|
||||
{discount > 0 && (
|
||||
<span className='text-gray-500 ml-1'>已优惠 ¥{discount.toFixed(0)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500'>结算方式: 个人支付 (微信 / 支付宝)</div>
|
||||
</div>
|
||||
<Button
|
||||
className='bg-[#269745] hover:bg-[#269745]/80 rounded-3xl text-white px-6 py-3 text-base font-medium'
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
确认支付 ¥{totalCurrent.toFixed(0)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExamDeliveryPanel = ({ client }: { client: ExamClient }) => (
|
||||
<div className='flex justify-center'>
|
||||
<div className='w-full rounded-2xl border shadow-sm px-6 py-4 text-xs text-gray-800'>
|
||||
|
||||
Reference in New Issue
Block a user