feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
kkfluous
2026-08-20 13:59:03 +08:00
parent 37a9f303ec
commit 62efef0ab9
72 changed files with 36773 additions and 965 deletions
@@ -0,0 +1,378 @@
import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react';
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import * as XLSX from 'xlsx';
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
import type {
HydrogenOverviewDetailGroup,
HydrogenOverviewDetailGroupBy,
HydrogenOverviewDetailRecord,
HydrogenOverviewDetailResponse,
} from '../../types';
import type { HydrogenVehicleScope } from '../../api';
type Selection = {
stationId?: number | null;
customerId?: number | null;
customerName?: string | null;
plateNo?: string | null;
};
type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue';
interface OverviewDrillTreeDialogProps {
title: string;
initialVehicleScope: HydrogenVehicleScope;
initialSelection?: Selection;
load: (
groupBy: HydrogenOverviewDetailGroupBy | null,
selection: Selection,
vehicleScope: HydrogenVehicleScope,
includeAll?: boolean,
) => Promise<HydrogenOverviewDetailResponse>;
onClose: () => void;
}
const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`;
const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`;
/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */
export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) {
const [root, setRoot] = useState<HydrogenOverviewDetailResponse | null>(null);
const [customers, setCustomers] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [vehicles, setVehicles] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
const [orders, setOrders] = useState<Record<string, HydrogenOverviewDetailRecord[]>>({});
const [openStations, setOpenStations] = useState<Record<string, boolean>>({});
const [openCustomers, setOpenCustomers] = useState<Record<string, boolean>>({});
const [openVehicles, setOpenVehicles] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState<string | null>('root');
const [stationFilter, setStationFilter] = useState('all');
const [customerFilter, setCustomerFilter] = useState('all');
const [plateFilter, setPlateFilter] = useState('all');
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>(initialVehicleScope);
const [exporting, setExporting] = useState(false);
const [sortKey, setSortKey] = useState<TreeSortKey>('kg');
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => {
setLoading('root');
setCustomers({});
setVehicles({});
setOrders({});
setOpenStations({});
setOpenCustomers({});
setOpenVehicles({});
try {
setRoot(await load('station', initialSelection, scope));
} catch {
setRoot(null);
} finally {
setLoading(null);
}
}, [initialSelection, load]);
useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]);
const toggleStation = async (station: HydrogenOverviewDetailGroup) => {
const key = String(station.id);
if (openStations[key]) {
setOpenStations(value => ({ ...value, [key]: false }));
return;
}
setOpenStations(value => ({ ...value, [key]: true }));
if (customers[key]) return;
setLoading(`station:${key}`);
try {
const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope);
setCustomers(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => {
const key = customerKey(stationId, customer);
if (openCustomers[key]) {
setOpenCustomers(value => ({ ...value, [key]: false }));
return;
}
setOpenCustomers(value => ({ ...value, [key]: true }));
if (vehicles[key]) return;
setLoading(`customer:${key}`);
try {
const data = await load('vehicle', {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
}, vehicleScope);
setVehicles(value => ({ ...value, [key]: data.groups }));
} finally {
setLoading(null);
}
};
const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => {
const key = vehicleKey(stationId, customer, vehicle);
if (openVehicles[key]) {
setOpenVehicles(value => ({ ...value, [key]: false }));
return;
}
setOpenVehicles(value => ({ ...value, [key]: true }));
if (orders[key]) return;
setLoading(`vehicle:${key}`);
try {
const data = await load(null, {
...initialSelection,
stationId: Number(stationId) || null,
customerId: Number(customer.id) || 0,
customerName: customer.name,
plateNo: vehicle.name,
}, vehicleScope);
setOrders(value => ({ ...value, [key]: data.records }));
} finally {
setLoading(null);
}
};
const stationOptions = root?.groups ?? [];
const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null;
const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : [];
const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null;
const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : [];
const visibleStations = useMemo(() => {
const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter);
return sortTreeGroups(filtered, sortKey, sortDirection);
}, [sortDirection, sortKey, stationFilter, stationOptions]);
const selectStation = (value: string) => {
setStationFilter(value);
setCustomerFilter('all');
setPlateFilter('all');
const station = stationOptions.find(item => String(item.id) === value);
if (station && !openStations[String(station.id)]) void toggleStation(station);
};
const selectCustomer = (value: string) => {
setCustomerFilter(value);
setPlateFilter('all');
const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value);
if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer);
};
const changeVehicleScope = (scope: HydrogenVehicleScope) => {
if (scope === vehicleScope) return;
setVehicleScope(scope);
setStationFilter('all');
setCustomerFilter('all');
setPlateFilter('all');
};
const changeSort = (nextKey: TreeSortKey) => {
const next = toggleSort(sortKey, sortDirection, nextKey);
setSortKey(next.key);
setSortDirection(next.direction);
};
const exportAll = async () => {
setExporting(true);
try {
const detail = await load(null, {
...initialSelection,
stationId: selectedStation ? Number(selectedStation.id) || null : null,
customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null,
customerName: selectedCustomer?.name ?? null,
plateNo: plateFilter === 'all' ? null : plateFilter,
}, vehicleScope, true);
const rows = detail.records.map(record => ({
加氢时间: record.refuelTime,
加氢站: record.stationName,
客户: record.customerName,
车牌: record.plateNo,
车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆',
数据来源: record.source,
核对状态: verifyText(record.verifyStatus),
订单编号: record.orderNo,
加氢量Kg: record.kg,
成本元: record.cost,
客户收入元: record.revenue,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单');
XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`);
if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。');
} finally {
setExporting(false);
}
};
if (typeof document === 'undefined') return null;
const summary = root?.summary;
return createPortal(
<div className="ehb-modal-overlay" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section className="ehb-modal-card" role="dialog" aria-modal="true" aria-label={`${title}穿透明细`}>
<header className="ehb-modal-head">
<div className="ehb-modal-head__title-group">
<div>
<h2 className="ehb-modal-head__title">{title}</h2>
<p className="ehb-modal-head__sub"> </p>
</div>
</div>
<button type="button" onClick={onClose} className="ehb-modal-close-btn" aria-label="关闭下钻弹层"><X size={17} /></button>
</header>
<div className="ehb-modal-body">
<div className="ehb-modal-meta-bar">
<Metric label="统计范围" value={title} />
<Metric label="加氢总量" value={`${number(summary?.kg ?? 0, 2)} Kg`} tone="text-sky-600" />
<Metric label="涉及加氢站" value={`${summary?.stationCount ?? 0}`} />
<Metric label="账本流水" value={`${summary?.recordCount ?? 0}`} />
</div>
<div className="ehb-modal-filter-row">
<div className="ehb-modal-filter-group">
<SearchSelect allLabel="全部加氢站" value={stationFilter} onChange={selectStation} options={stationOptions.map(item => ({ value: String(item.id), label: item.name }))} />
<SearchSelect allLabel="全部客户" value={customerFilter} onChange={selectCustomer} disabled={!selectedStation} options={customerOptions.map(item => ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} />
<SearchSelect allLabel="全部车辆" value={plateFilter} onChange={setPlateFilter} disabled={!selectedCustomer} options={plateOptions.map(item => ({ value: item.name, label: item.name }))} />
<div className="flex h-8 overflow-hidden rounded-md border border-slate-300 bg-white text-[11px]">
{([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => (
<button key={item.key} type="button" onClick={() => changeVehicleScope(item.key)} className={`inline-flex items-center gap-1 px-2.5 font-medium transition-colors ${vehicleScope === item.key ? 'bg-sky-50 text-sky-700 shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}>
{item.key !== 'all' ? <Truck size={13} /> : null}{item.label}
</button>
))}
</div>
<p className="ehb-modal-hint-text"></p>
</div>
<button type="button" onClick={() => void exportAll()} disabled={exporting} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700 disabled:opacity-60">
<Download size={14} />{exporting ? '正在导出…' : '导出 Excel 穿透账单'}
</button>
</div>
<p className="mb-2 text-center text-[11px] text-slate-400 md:hidden"></p>
<div className="ehb-modal-table-wrap is-v-scroll">
<table className="ehb-modal-table min-w-[1080px]">
<thead className="sticky top-0 z-10">
<tr>
<th className="min-w-[240px] border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="加氢站 / 客户 / 车辆与凭证链路" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="类型 / 归属" sortKey="ownership" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="数据来源及凭证号" sortKey="source" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="核对状态" sortKey="verify" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢笔数" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢总量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="成本 (元)" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="客户收入 (元)" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
</tr>
</thead>
<tbody>
{visibleStations.map(station => {
const stationKey = String(station.id);
return <StationBranch key={`${stationKey}:${station.name}`} station={station} expanded={Boolean(openStations[stationKey])} loadingKey={loading} customers={customers[stationKey] ?? []} customerFilter={customerFilter} plateFilter={plateFilter} openCustomers={openCustomers} vehicles={vehicles} orders={orders} openVehicles={openVehicles} sortKey={sortKey} sortDirection={sortDirection} onStation={() => void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />;
})}
</tbody>
</table>
</div>
{!root && loading === null ? <div className="mt-4 flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500"><Database size={18} /></div> : null}
</div>
</section>
</div>, document.body,
);
}
function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) {
return <div className="ehb-modal-meta-item"><div className="ehb-modal-meta-label">{label}</div><div className={`ehb-modal-meta-val ${tone}`} title={value}>{value}</div></div>;
}
function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState('');
const selected = options.find(option => option.value === value);
const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase()));
const pick = (next: string) => {
onChange(next);
setOpen(false);
setKeyword('');
};
return <div className={`ehb-bi-search-select w-[170px] ${disabled ? 'is-disabled' : ''}`}>
<button type="button" aria-label={allLabel} aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${selected ? 'has-value' : ''}`}>
<span className="ehb-bi-search-select__label">{selected?.label ?? allLabel}</span><ChevronDown size={14} className="ehb-bi-search-select__chevron" />
</button>
{open ? <div className="ehb-bi-search-select__dropdown">
<label className="ehb-bi-search-select__search"><Search size={13} /><input autoFocus value={keyword} onChange={event => setKeyword(event.target.value)} placeholder={`搜索${allLabel.replace('全部', '')}`} /></label>
<div className="ehb-bi-search-select__list">
<button type="button" onClick={() => pick('all')} className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}>{allLabel}</button>
{filtered.map(option => <button key={option.value} type="button" onClick={() => pick(option.value)} className={`ehb-bi-search-select__item ${value === option.value ? 'is-selected' : ''}`}>{option.label}</button>)}
{filtered.length === 0 ? <p className="ehb-bi-search-select__empty"></p> : null}
</div>
</div> : null}
</div>;
}
function ExpandMark({ open }: { open: boolean }) { return open ? <ChevronDown size={15} className="shrink-0 text-sky-600" /> : <ChevronRight size={15} className="shrink-0 text-sky-600" />; }
function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: {
station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record<string, boolean>; vehicles: Record<string, HydrogenOverviewDetailGroup[]>; orders: Record<string, HydrogenOverviewDetailRecord[]>; openVehicles: Record<string, boolean>; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void;
}) {
const stationKey = String(station.id);
const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection);
return <>
<TreeRow className={expanded ? 'bg-sky-50 font-bold' : 'bg-slate-50 font-bold'} onClick={onStation} label={<><ExpandMark open={expanded} />{station.name}<span className="ml-2 text-[11px] font-medium text-slate-400">({station.customerCount} )</span></>} ownership="-" source="全量自动归集" verify="-" group={station} />
{expanded && visibleCustomers.map(customer => {
const key = customerKey(stationKey, customer);
const childVehicles = vehicles[key] ?? [];
const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection);
return <Fragment key={key}>
<TreeRow className={openCustomers[key] ? 'bg-slate-100' : 'bg-white'} onClick={() => onCustomer(customer)} indent={1} label={<><ExpandMark open={Boolean(openCustomers[key])} /> {customer.name}</>} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} />
{openCustomers[key] && visibleVehicles.map(vehicle => {
const key = vehicleKey(stationKey, customer, vehicle);
return <Fragment key={key}>
<TreeRow className={openVehicles[key] ? 'bg-slate-100 text-[11px]' : 'bg-white text-[11px]'} onClick={() => onVehicle(customer, vehicle)} indent={2} label={<><ExpandMark open={Boolean(openVehicles[key])} /><strong>{vehicle.name}</strong><span className="ml-1 text-[10px] font-normal text-slate-400">({vehicle.recordCount} )</span></>} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} />
{openVehicles[key] && <OrderRows orders={orders[key] ?? []} sortKey={sortKey} sortDirection={sortDirection} />}
</Fragment>;
})}
{openCustomers[key] && loadingKey === `customer:${key}` ? <LoadingRow text="正在读取车辆汇总…" /> : null}
</Fragment>;
})}
{expanded && loadingKey === `station:${stationKey}` ? <LoadingRow text="正在读取客户汇总…" /> : null}
</>;
}
function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) {
return <tr className={`${className} cursor-pointer border-b border-slate-100 transition-colors hover:bg-slate-50`} onClick={onClick}><td className="px-3 py-2.5" style={{ paddingLeft: `${12 + indent * 16}px` }}><span className="inline-flex items-center gap-1.5">{label}</span></td><td className="px-3 py-2.5 text-slate-500">{ownership}</td><td className="px-3 py-2.5 text-[11px] text-slate-500">{source}</td><td className="px-3 py-2.5">{verify === '-' ? <span className="text-slate-400">-</span> : <VerifyTag value={verify} />}</td><td className="px-3 py-2.5 text-right font-mono text-slate-600">{group.recordCount} </td><td className="px-3 py-2.5 text-right font-mono font-semibold text-sky-600">{number(group.kg, 3)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.cost)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.revenue)}</td></tr>;
}
function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) {
return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => <tr key={order.id} className="border-b border-slate-100 bg-slate-50 text-[11px]"><td className="px-3 py-2" style={{ paddingLeft: '76px' }}><span className="mr-1.5 text-slate-300"></span><span className="mr-1 text-[10px] text-slate-500"></span><span className="font-mono font-semibold text-sky-600">{order.orderNo || order.id}</span><span className="ml-1 text-[10px] text-slate-500">({order.refuelTime})</span></td><td className="px-3 py-2 text-[10px] text-slate-500"> ¥{order.costPrice.toFixed(2)}/Kg</td><td className="px-3 py-2">{order.source}</td><td className="px-3 py-2"><VerifyTag value={verifyText(order.verifyStatus)} /></td><td className="px-3 py-2 text-right font-mono text-slate-400">1 </td><td className="px-3 py-2 text-right font-mono text-sky-600">{number(order.kg, 3)}</td><td className="px-3 py-2 text-right font-mono">{number(order.cost)}</td><td className="px-3 py-2 text-right font-mono">{number(order.revenue)}</td></tr>)}</>;
}
function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'ownership') return row.vehicleScope ?? '';
if (key === 'verify') return row.verifyStatus ?? '';
return row[key === 'name' ? 'name' : key] ?? '';
});
}
function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) {
return sortBy(rows, sortKey, sortDirection, (row, key) => {
if (key === 'name') return row.orderNo || row.id;
if (key === 'ownership') return row.vehicleScope;
if (key === 'verify') return row.verifyStatus;
if (key === 'recordCount') return 1;
return row[key] ?? '';
});
}
function LoadingRow({ text }: { text: string }) { return <tr><td className="px-8 py-3 text-xs text-slate-400" colSpan={8}>{text}</td></tr>; }
function verifyText(value?: string) {
const normalized = (value ?? '').toUpperCase();
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对';
if (normalized === 'PARTIAL') return '部分核对';
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
return '未核对';
}
function VerifyTag({ value }: { value: string }) {
const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500';
return <span className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-medium ${color}`}>{value}</span>;
}