Compare commits
3
Commits
a237df8b75
...
4214dca4af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4214dca4af | ||
|
|
462b2ecff3 | ||
|
|
a7693b8591 |
@@ -0,0 +1,120 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
export interface DateRange { start: string; end: string }
|
||||
interface DateRangePickerProps {
|
||||
value: DateRange;
|
||||
onChange: (range: DateRange) => void;
|
||||
quickPicks: Array<{ label: string; range: DateRange }>;
|
||||
label?: string;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
maxDays?: number;
|
||||
}
|
||||
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||
|
||||
export function DateRangePicker({ value, onChange, quickPicks, label = '统计日期', testId, className = '', maxDays = Infinity }: DateRangePickerProps) {
|
||||
const titleId = useId();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
const [month, setMonth] = useState(() => parseDate(value.start));
|
||||
const [hovered, setHovered] = useState('');
|
||||
const today = ymd(new Date());
|
||||
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
||||
const valid = count > 0 && count <= maxDays;
|
||||
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const previewEnd = draft.end || hovered || draft.start;
|
||||
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
||||
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
||||
const open = () => {
|
||||
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
||||
dialog.current?.showModal();
|
||||
};
|
||||
const choose = (date: string) => {
|
||||
if (selecting === 'start') {
|
||||
setDraft({ start: date, end: '' }); setSelecting('end');
|
||||
} else {
|
||||
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
||||
setSelecting('start');
|
||||
}
|
||||
setHovered('');
|
||||
};
|
||||
const calendar = (offset: number) => {
|
||||
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
||||
const year = current.getFullYear(), m = current.getMonth();
|
||||
const padding = (current.getDay() + 6) % 7;
|
||||
const length = new Date(year, m + 1, 0).getDate();
|
||||
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
||||
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
||||
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
||||
{Array.from({ length }, (_, i) => {
|
||||
const date = ymd(new Date(year, m, i + 1));
|
||||
const endpoint = date === draft.start || date === draft.end;
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
||||
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
||||
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
||||
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
return <>
|
||||
<button type="button" data-testid={testId} aria-haspopup="dialog" onClick={open}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500 ${className}`}>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-slate-400">{label}</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||
</span>
|
||||
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||
</button>
|
||||
<dialog ref={dialog} aria-labelledby={titleId} onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
||||
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 id={titleId} className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
||||
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
||||
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
||||
<div className="flex gap-2">
|
||||
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
||||
</select>
|
||||
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
||||
<span role="status" className={`text-xs ${count > maxDays ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > maxDays ? `最多选择 ${maxDays} 天` : `已选 ${count} 天(包含首尾)`}</span>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
||||
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
}
|
||||
@@ -1,118 +1,18 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ArrowRight, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { DateRangePicker } from '../../../../components/ui/DateRangePicker';
|
||||
import { getRecentFlowRange, getWeeklyFlowRange, type DateRange } from '../../model';
|
||||
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const ymd = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||
|
||||
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
const [month, setMonth] = useState(() => parseDate(value.start));
|
||||
const [hovered, setHovered] = useState('');
|
||||
const today = ymd(new Date());
|
||||
const count = draft.end ? dayCount(draft.start, draft.end) : 0;
|
||||
const valid = count > 0 && count <= 370;
|
||||
const monthStart = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
const previewEnd = draft.end || hovered || draft.start;
|
||||
const rangeStart = draft.start < previewEnd ? draft.start : previewEnd;
|
||||
const rangeEnd = draft.start > previewEnd ? draft.start : previewEnd;
|
||||
const quickPicks = [
|
||||
return <DateRangePicker value={value} onChange={onChange}
|
||||
label={`统计日期 · ${dateLabel}`} testId="asset-flow-date-range" className="mt-3" maxDays={370}
|
||||
quickPicks={[
|
||||
{ label: '今天', range: { start: today, end: today } },
|
||||
{ label: '昨天', range: { start: ymd(shiftDay(new Date(), -1)), end: ymd(shiftDay(new Date(), -1)) } },
|
||||
{ label: '业务周', range: getWeeklyFlowRange() },
|
||||
{ label: '近 7 天', range: getRecentFlowRange() },
|
||||
{ label: '近 30 天', range: { start: ymd(shiftDay(new Date(), -29)), end: today } },
|
||||
];
|
||||
const open = () => {
|
||||
setDraft(value); setSelecting('start'); setMonth(parseDate(value.start)); setHovered('');
|
||||
dialog.current?.showModal();
|
||||
};
|
||||
const choose = (date: string) => {
|
||||
if (selecting === 'start') {
|
||||
setDraft({ start: date, end: '' }); setSelecting('end');
|
||||
} else {
|
||||
setDraft(date < draft.start ? { start: date, end: draft.start } : { start: draft.start, end: date });
|
||||
setSelecting('start');
|
||||
}
|
||||
setHovered('');
|
||||
};
|
||||
const calendar = (offset: number) => {
|
||||
const current = new Date(monthStart.getFullYear(), monthStart.getMonth() + offset, 1);
|
||||
const year = current.getFullYear(), m = current.getMonth();
|
||||
const padding = (current.getDay() + 6) % 7;
|
||||
const length = new Date(year, m + 1, 0).getDate();
|
||||
return <div className={offset ? 'hidden sm:block' : ''} key={offset}>
|
||||
<div className="mb-3 text-center text-sm font-bold text-slate-700">{year} 年 {m + 1} 月</div>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{weekdays.map(day => <div key={day} className="py-2 text-[11px] font-medium text-slate-400">{day}</div>)}
|
||||
{Array.from({ length: padding }, (_, i) => <div key={`blank-${i}`} />)}
|
||||
{Array.from({ length }, (_, i) => {
|
||||
const date = ymd(new Date(year, m, i + 1));
|
||||
const endpoint = date === draft.start || date === draft.end;
|
||||
const inRange = date >= rangeStart && date <= rangeEnd;
|
||||
return <button key={date} type="button" aria-label={date} aria-pressed={endpoint}
|
||||
onClick={() => choose(date)} onMouseEnter={() => { if (!draft.end) setHovered(date); }}
|
||||
className={`relative my-0.5 h-10 text-xs font-semibold transition focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-blue-500 ${endpoint ? 'rounded-xl bg-blue-600 text-white shadow-sm' : inRange ? 'bg-blue-50 text-blue-700' : 'rounded-xl text-slate-600 hover:bg-slate-100'} ${date === today && !endpoint ? 'font-black text-blue-600' : ''}`}>
|
||||
{i + 1}{date === today && <span className={`absolute bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${endpoint ? 'bg-white' : 'bg-blue-500'}`} />}
|
||||
</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
return <>
|
||||
<button type="button" data-testid="asset-flow-date-range" aria-haspopup="dialog" onClick={open}
|
||||
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||
</span>
|
||||
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||
<ChevronDown size={16} className="shrink-0 text-slate-400" />
|
||||
</button>
|
||||
<dialog ref={dialog} aria-labelledby="flow-date-title" onClick={e => { if (e.target === e.currentTarget) dialog.current?.close(); }}
|
||||
className="fixed inset-0 m-auto max-h-[90dvh] w-[calc(100%-24px)] max-w-[680px] overflow-y-auto rounded-3xl border-0 bg-white p-0 text-slate-700 shadow-2xl backdrop:bg-slate-950/30 backdrop:backdrop-blur-sm">
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div><h3 id="flow-date-title" className="text-base font-bold text-slate-900">选择统计日期</h3><p className="mt-1 text-xs text-slate-400">先选开始日期,再选结束日期</p></div>
|
||||
<button type="button" aria-label="关闭日期选择" onClick={() => dialog.current?.close()} className="rounded-full p-2 text-slate-400 hover:bg-slate-100"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{quickPicks.map(pick => <button key={pick.label} type="button" onClick={() => { setDraft(pick.range); setMonth(parseDate(pick.range.start)); setSelecting('start'); setHovered(''); }}
|
||||
className={`rounded-lg border px-3 py-2 text-xs font-medium transition ${draft.start === pick.range.start && draft.end === pick.range.end ? 'border-blue-200 bg-blue-50 text-blue-600' : 'border-slate-100 text-slate-500 hover:border-blue-200 hover:bg-blue-50'}`}>{pick.label}</button>)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
{(['start', 'end'] as const).map(field => <button key={field} type="button" onClick={() => { setSelecting(field); setHovered(''); setMonth(parseDate(draft[field] || draft.start)); }}
|
||||
className={`rounded-xl border px-3 py-2.5 text-left transition ${selecting === field ? 'border-blue-400 bg-blue-50/60 ring-2 ring-blue-50' : 'border-slate-200 bg-slate-50/50'}`}>
|
||||
<span className="block text-[10px] text-slate-400">{field === 'start' ? '开始日期' : '结束日期'}</span><span className="mt-1 block text-sm font-bold">{draft[field] || '请选择结束日期'}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<button type="button" aria-label="上个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronLeft size={18} /></button>
|
||||
<div className="flex gap-2">
|
||||
<select aria-label="年份" value={monthStart.getFullYear()} onChange={e => setMonth(new Date(Number(e.target.value), monthStart.getMonth(), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: Math.max(new Date().getFullYear() + 1, monthStart.getFullYear()) - Math.min(2016, monthStart.getFullYear()) + 1 }, (_, i) => Math.min(2016, monthStart.getFullYear()) + i).map(year => <option key={year} value={year}>{year} 年</option>)}
|
||||
</select>
|
||||
<select aria-label="月份" value={monthStart.getMonth()} onChange={e => setMonth(new Date(monthStart.getFullYear(), Number(e.target.value), 1))} className="rounded-lg bg-slate-50 px-2 py-1.5 text-xs font-semibold">
|
||||
{Array.from({ length: 12 }, (_, i) => <option key={i} value={i}>{i + 1} 月</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" aria-label="下个月" onClick={() => setMonth(new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1))} className="rounded-lg p-2 hover:bg-slate-100"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-6 sm:grid-cols-2" onMouseLeave={() => setHovered('')}>{calendar(0)}{calendar(1)}</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-6">
|
||||
<span role="status" className={`text-xs ${count > 370 ? 'text-red-500' : 'text-slate-400'}`}>{!draft.end ? '请选择结束日期' : count > 370 ? '最多选择 370 天' : `已选 ${count} 天(包含首尾)`}</span>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button type="button" onClick={() => dialog.current?.close()} className="rounded-xl px-3 py-2.5 text-xs font-semibold text-slate-500 hover:bg-slate-100">取消</button>
|
||||
<button type="button" disabled={!valid} onClick={() => { onChange(draft); dialog.current?.close(); }} className="flex items-center gap-1 rounded-xl bg-blue-600 px-4 py-2.5 text-xs font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"><Check size={14} />应用日期</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
]} />;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
|
||||
}, [metric, revision, source?.url]);
|
||||
const records = useMemo(() => data?.rows || [], [data]);
|
||||
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
|
||||
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
|
||||
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'level' ? value || '未标注' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
|
||||
const options = available.map(key => {
|
||||
const otherFilters = { ...filters }; delete otherFilters[key];
|
||||
const counts = new Map<string, number>();
|
||||
@@ -78,7 +78,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
|
||||
<div className="analysis-scroll">
|
||||
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
|
||||
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
|
||||
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
|
||||
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '故障程度', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <tr key={row.id}><td><strong>{row.plate || '未关联车辆'}</strong><small>{row.code || '未编号'}</small></td><td>{row.level || '未标注'}</td><td>{row.handlers.map(h => h.label).join('、') || '未指派'}</td><td>{row.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
|
||||
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
|
||||
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
|
||||
</> : <>
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function FaultManagement(){
|
||||
</section>
|
||||
<div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div>
|
||||
<div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div>
|
||||
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
|
||||
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、故障程度、工单状态、车型、区域、客户等 13 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
|
||||
<DashboardPanels data={data} drill={drill}/>
|
||||
<section className="fd-panel fd-focus" aria-label="重点故障跟进">
|
||||
<div className="fd-panel-title">
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
|
||||
import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
|
||||
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
|
||||
|
||||
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
|
||||
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus' | 'level';
|
||||
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
|
||||
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]);
|
||||
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' };
|
||||
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus', 'level'] : [d]);
|
||||
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态', level: '故障程度' };
|
||||
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
|
||||
export interface FaultAnalysisGroup {
|
||||
key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
|
||||
@@ -14,6 +14,7 @@ export interface FaultAnalysisGroup {
|
||||
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
|
||||
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
|
||||
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
|
||||
if (dimension === 'level') return [row.level?.trim() || '未标注'];
|
||||
return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]];
|
||||
}
|
||||
export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronRight, Fuel, Plug, TrendingUp, Truck } from 'lucide-react';
|
||||
import { Check, ChevronRight, Fuel, Layers, Plug, Search, TrendingUp, Truck, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, ReferenceLine } from 'recharts';
|
||||
import TrendBadge from './TrendBadge';
|
||||
import { fetchHydrogenDaily } from './api';
|
||||
import type { CustomerType, DateQuickPick, HydrogenDailyRow } from './types';
|
||||
import {
|
||||
filterHydrogenRows,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
@@ -13,12 +14,14 @@ import {
|
||||
type RangeMode,
|
||||
} from './hydrogen-daily/model';
|
||||
import DailyRangeControls from './daily-range/DailyRangeControls';
|
||||
import { DateRangePicker } from '../../components/ui/DateRangePicker';
|
||||
import { EmptyState, ErrorState, LoadingState, MetricTile, SurfaceCard } from '../../components/ui/surface';
|
||||
|
||||
export default function HydrogenDaily() {
|
||||
const [pick, setPick] = useState<RangeMode>('last15');
|
||||
const [dateRange, setDateRange] = useState(() => getQuickRange('last15'));
|
||||
const [customer, setCustomer] = useState<CustomerType>('lingniu');
|
||||
const [customer, setCustomer] = useState<CustomerType>('all');
|
||||
const [stationSearch, setStationSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [rows, setRows] = useState<HydrogenDailyRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -38,6 +41,8 @@ export default function HydrogenDaily() {
|
||||
return () => { cancelled = true; };
|
||||
}, [pick, customer, effectiveRange.start, effectiveRange.end]);
|
||||
|
||||
const filteredRows = useMemo(() => filterHydrogenRows(rows, stationSearch), [rows, stationSearch]);
|
||||
|
||||
const {
|
||||
trendData,
|
||||
totalKg,
|
||||
@@ -47,7 +52,7 @@ export default function HydrogenDaily() {
|
||||
peakDay,
|
||||
lowDay,
|
||||
zeroDays,
|
||||
} = useMemo(() => summarizeHydrogenRows(rows), [rows]);
|
||||
} = useMemo(() => summarizeHydrogenRows(filteredRows), [filteredRows]);
|
||||
const scopeLabel = getRangeModeLabel(pick);
|
||||
const rangeText = `${effectiveRange.start} 至 ${effectiveRange.end}`;
|
||||
|
||||
@@ -78,13 +83,83 @@ export default function HydrogenDaily() {
|
||||
onCustomPick={() => setPick('custom')}
|
||||
onDateRangeChange={updateDateRange}
|
||||
onCustomerChange={setCustomer}
|
||||
dateControl={
|
||||
<DateRangePicker
|
||||
value={effectiveRange}
|
||||
label="加氢日期"
|
||||
testId="hydrogen-date-range"
|
||||
quickPicks={([
|
||||
{ id: 'thisWeek', label: '本周' },
|
||||
{ id: 'thisMonth', label: '本月' },
|
||||
{ id: 'last15', label: '近 15 天' },
|
||||
] as const).map(option => ({ label: option.label, range: getQuickRange(option.id) }))}
|
||||
onChange={range => {
|
||||
setDateRange(range);
|
||||
const matching = (['thisWeek', 'thisMonth', 'last15'] as const).find(option => {
|
||||
const preset = getQuickRange(option);
|
||||
return preset.start === range.start && preset.end === range.end;
|
||||
});
|
||||
setPick(matching ?? 'custom');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mt-3 space-y-3">
|
||||
<fieldset>
|
||||
<legend className="mb-2 px-1 text-[11px] font-semibold text-slate-500">车辆归属</legend>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{([
|
||||
{ value: 'all', label: '全部车辆', description: '内外部汇总', icon: Layers },
|
||||
{ value: 'lingniu', label: '内部车辆', description: '羚牛车辆', icon: Truck },
|
||||
{ value: 'external', label: '外部车辆', description: '佛山站外部订单', icon: Truck },
|
||||
] as const).map(option => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<label key={option.value} className="relative min-w-0 cursor-pointer">
|
||||
<input type="radio" name="hydrogen-customer" aria-label={option.label} value={option.value} checked={customer === option.value}
|
||||
onChange={() => setCustomer(option.value)} className="peer sr-only" />
|
||||
<span className="flex h-full min-h-[94px] flex-col gap-2 rounded-2xl border border-slate-200 bg-white p-3 transition hover:border-blue-300 hover:bg-blue-50/40 peer-checked:border-blue-400 peer-checked:bg-blue-50/70 peer-checked:shadow-sm peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue-500">
|
||||
<span className="flex items-center justify-between">
|
||||
<Icon size={17} aria-hidden="true" className={customer === option.value ? 'text-blue-600' : 'text-slate-400'} />
|
||||
<span aria-hidden="true" className={`flex h-4 w-4 items-center justify-center rounded-full ${customer === option.value ? 'bg-blue-600 text-white' : 'border border-slate-200 bg-white'}`}>
|
||||
{customer === option.value && <Check size={11} strokeWidth={3} />}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`text-[12px] font-bold ${customer === option.value ? 'text-blue-700' : 'text-slate-700'}`}>{option.label}</span>
|
||||
<span className="text-[10px] leading-tight text-slate-500">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="min-w-0 rounded-xl border border-slate-100 bg-slate-50 px-3 py-2 focus-within:ring-2 focus-within:ring-blue-200">
|
||||
<label htmlFor="hydrogen-station-search" className="block text-[10px] font-black text-slate-400">搜索加氢站</label>
|
||||
<div className="mt-1 flex h-7 items-center gap-2">
|
||||
<Search size={15} className="shrink-0 text-slate-400" aria-hidden="true" />
|
||||
<input
|
||||
id="hydrogen-station-search"
|
||||
type="search"
|
||||
value={stationSearch}
|
||||
onChange={event => setStationSearch(event.target.value)}
|
||||
placeholder="输入加氢站名称,例如:佛山"
|
||||
className="min-w-0 flex-1 bg-transparent text-[12px] font-bold text-slate-800 outline-none placeholder:font-normal placeholder:text-slate-400 [&::-webkit-search-cancel-button]:hidden"
|
||||
/>
|
||||
{stationSearch && (
|
||||
<button type="button" aria-label="清除加氢站搜索" onClick={() => setStationSearch('')} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-200 hover:text-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DailyRangeControls>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
|
||||
<MetricTile icon={Truck} label="车辆归属" value={{ all: '全部', lingniu: '内部', external: '外部' }[customer]} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
|
||||
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
|
||||
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper={stationSearch.trim() ? `搜索:${stationSearch.trim()}` : '按明细站点去重'} tone="slate" />
|
||||
</div>
|
||||
|
||||
{/* 时段加氢量柱图 */}
|
||||
@@ -183,11 +258,11 @@ export default function HydrogenDaily() {
|
||||
{/* 主行 + 子行 */}
|
||||
{error ? (
|
||||
<div className="p-3"><ErrorState message={error} /></div>
|
||||
) : rows === null ? (
|
||||
) : filteredRows === null ? (
|
||||
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
|
||||
) : rows.length === 0 || totalKg === 0 ? (
|
||||
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
|
||||
) : rows.map(r => {
|
||||
) : filteredRows.length === 0 || totalKg === 0 ? (
|
||||
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={stationSearch.trim() ? '当前时段和车辆归属下没有匹配的加氢站数据,请调整关键词或清除搜索' : customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
|
||||
) : filteredRows.map(r => {
|
||||
const open = expanded.has(r.date);
|
||||
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
|
||||
const abnormalBg = isAbnormal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Truck } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SurfaceCard } from '../../../components/ui/surface';
|
||||
import type { CustomerType, DateQuickPick } from '../types';
|
||||
import { QUICK_PICK_OPTIONS, type RangeMode } from './model';
|
||||
@@ -11,6 +12,8 @@ interface DailyRangeControlsProps {
|
||||
onCustomPick: () => void;
|
||||
onDateRangeChange: (field: 'start' | 'end', value: string) => void;
|
||||
onCustomerChange: (customer: CustomerType) => void;
|
||||
children?: ReactNode;
|
||||
dateControl?: ReactNode;
|
||||
}
|
||||
|
||||
export default function DailyRangeControls({
|
||||
@@ -21,9 +24,12 @@ export default function DailyRangeControls({
|
||||
onCustomPick,
|
||||
onDateRangeChange,
|
||||
onCustomerChange,
|
||||
children,
|
||||
dateControl,
|
||||
}: DailyRangeControlsProps) {
|
||||
return (
|
||||
<SurfaceCard className="p-2 md:p-3">
|
||||
{dateControl ?? <>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{QUICK_PICK_OPTIONS.map(opt => (
|
||||
<button
|
||||
@@ -73,7 +79,9 @@ export default function DailyRangeControls({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
</>}
|
||||
|
||||
{children ?? <div className="mt-2 grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||||
{(['lingniu', 'external'] as const).map(option => (
|
||||
<button
|
||||
key={option}
|
||||
@@ -86,7 +94,7 @@ export default function DailyRangeControls({
|
||||
{option === 'external' ? '外部车辆' : '羚牛车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</SurfaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { HydrogenDailyRow } from '../types.js';
|
||||
import {
|
||||
filterHydrogenRows,
|
||||
getQuickRange,
|
||||
getRangeModeLabel,
|
||||
normalizeRange,
|
||||
@@ -82,3 +83,53 @@ test('空数据保持零值且没有峰谷日', () => {
|
||||
zeroDays: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const searchRows: HydrogenDailyRow[] = [
|
||||
{ date: '2026-09-17', totalKg: 50, chainPct: -0.75, customerType: 'all', stations: [
|
||||
{ name: '嘉兴站', kg: 50, pricePerKg: 30, chainPct: 0 },
|
||||
] },
|
||||
{ date: '2026-09-16', totalKg: 200, chainPct: 1, customerType: 'all', stations: [
|
||||
{ name: '佛山南海羚牛加氢站', kg: 30, pricePerKg: 38, chainPct: 2 },
|
||||
{ name: '佛山豪汇石油加氢站', kg: 10, pricePerKg: 35, chainPct: 0 },
|
||||
{ name: '嘉兴站', kg: 160, pricePerKg: 30, chainPct: 1 },
|
||||
] },
|
||||
{ date: '2026-09-15', totalKg: 100, chainPct: 0, customerType: 'all', stations: [
|
||||
{ name: '佛山南海羚牛加氢站', kg: 10, pricePerKg: 38, chainPct: 0 },
|
||||
{ name: '嘉兴站', kg: 90, pricePerKg: 30, chainPct: 0 },
|
||||
] },
|
||||
];
|
||||
|
||||
test('站名搜索同步更新日明细、总量、趋势和环比,保留完整日期及原始数据', () => {
|
||||
const before = structuredClone(searchRows);
|
||||
const filtered = filterHydrogenRows(searchRows, ' 佛山 ')!;
|
||||
assert.deepEqual(filtered.map(row => row.date), searchRows.map(row => row.date));
|
||||
assert.deepEqual(filtered.map(row => row.totalKg), [0, 40, 10]);
|
||||
assert.deepEqual(filtered.map(row => row.chainPct), [-1, 3, 0]);
|
||||
assert.equal(filtered[1].stations.length, 2);
|
||||
assert.equal(filtered[1].stations[0].pricePerKg, 38);
|
||||
assert.equal(filtered[1].stations[0].chainPct, 2);
|
||||
const summary = summarizeHydrogenRows(filtered);
|
||||
assert.equal(summary.totalKg, 50);
|
||||
assert.equal(summary.stationCount, 2);
|
||||
assert.equal(summary.activeDays, 2);
|
||||
assert.equal(summary.avgKg, 25);
|
||||
assert.equal(summary.zeroDays, 1);
|
||||
assert.deepEqual(summary.trendData.map(row => row.totalKg), [10, 40, 0]);
|
||||
assert.deepEqual(searchRows, before);
|
||||
});
|
||||
|
||||
test('无匹配关键词显示零结果,清除或空白搜索恢复全量,加载中保持加载状态', () => {
|
||||
const missing = filterHydrogenRows(searchRows, '不存在的站点')!;
|
||||
assert.equal(missing.length, 3);
|
||||
assert.ok(missing.every(row => row.totalKg === 0 && row.chainPct === 0 && row.stations.length === 0));
|
||||
assert.strictEqual(filterHydrogenRows(searchRows, ''), searchRows);
|
||||
assert.strictEqual(filterHydrogenRows(searchRows, ' '), searchRows);
|
||||
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '')).totalKg, 350);
|
||||
assert.equal(filterHydrogenRows(null, '佛山'), null);
|
||||
assert.deepEqual(filterHydrogenRows([], '佛山'), []);
|
||||
});
|
||||
|
||||
test('完整站名可定位单站,搜索将特殊字符按普通文字匹配', () => {
|
||||
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '佛山南海羚牛加氢站')).totalKg, 40);
|
||||
assert.equal(summarizeHydrogenRows(filterHydrogenRows(searchRows, '%')).totalKg, 0);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,28 @@ export {
|
||||
type RangeMode,
|
||||
} from '../daily-range/model';
|
||||
|
||||
// 接口已返回所选车辆范围内的全部日/站点数据;搜索后统一重算日合计与环比。
|
||||
export function filterHydrogenRows(rows: HydrogenDailyRow[] | null, stationSearch: string): HydrogenDailyRow[] | null {
|
||||
const keyword = stationSearch.trim().toLocaleLowerCase();
|
||||
if (!rows || !keyword) return rows;
|
||||
|
||||
const filtered = rows.map(row => {
|
||||
const stations = row.stations.filter(station => station.name.toLocaleLowerCase().includes(keyword));
|
||||
return {
|
||||
...row,
|
||||
stations,
|
||||
totalKg: Math.round(stations.reduce((sum, station) => sum + station.kg, 0) * 100) / 100,
|
||||
chainPct: 0,
|
||||
};
|
||||
});
|
||||
let previousKg = 0;
|
||||
for (const row of [...filtered].sort((a, b) => a.date.localeCompare(b.date))) {
|
||||
row.chainPct = previousKg > 0 ? (row.totalKg - previousKg) / previousKg : 0;
|
||||
previousKg = row.totalKg;
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function summarizeHydrogenRows(rows: HydrogenDailyRow[] | null) {
|
||||
const source = rows ?? [];
|
||||
// 图表固定按日期升序;复制数组避免改变接口返回及表格原始顺序。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CustomerType = 'external' | 'lingniu';
|
||||
export type CustomerType = 'external' | 'lingniu' | 'all';
|
||||
export type DateQuickPick = 'thisWeek' | 'thisMonth' | 'last15';
|
||||
|
||||
export interface HydrogenKpi {
|
||||
|
||||
Reference in New Issue
Block a user