Files
ln-bi/src/modules/assets/components/analysis/AssetAnalysisModal.tsx
T
shishengliang f1b3f8d3da
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): add dimension analysis and authorized user previews
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
2026-09-28 12:29:51 +08:00

271 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react';
import { fetchJson } from '../../../../auth/api-client';
import Blur from '../../../../components/Blur';
import type { AssetAnalysisResponse, AssetAnalysisDimension } from '../../../../shared/assets/analytics';
import { ANALYSIS_META, DIMENSION_LABELS, compareCustomLabels, getCustomAnalysis, getAvailableDimensions, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type CustomAnalysisRow, type AnalysisSort, type AnalysisView } from '../../analytics-model';
import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from '../../dimension-layout';
import { AnalysisControls } from './AnalysisControls';
import { DimensionPicker } from './DimensionPicker';
import { ProgressiveRowsStatus } from './ProgressiveRowsStatus';
import { useProgressiveRows } from './useProgressiveRows';
import { buildVehicleModalRequest, formatLocalDateTime, type VehicleModalSelection } from '../../model';
import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api';
import type { VehicleListItem } from '../../types';
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
import { useDialogFocus } from '../useDialogFocus';
import './asset-analysis.css';
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
const number = (value: number) => value.toLocaleString('zh-CN');
function initialView(selection: VehicleModalSelection): AnalysisView {
if (selection.category === 'Abnormal') return 'abnormal';
if (selection.category === 'Inventory') return 'inventory';
if (selection.category === 'Pending') return 'pending';
if (selection.source === 'department') return 'department';
if (selection.source === 'customer') return 'customer';
if (selection.category === 'Operating') return 'operating';
return 'assets';
}
export function AssetAnalysisModal({ selection, subject, close }: Props) {
if (!selection) return null;
return <AnalysisDialog key={JSON.stringify([selection, subject])} selection={selection} subject={subject} close={close} />;
}
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
useDialogFocus(true, dialog, close);
const [data, setData] = useState<AssetAnalysisResponse | null>(null);
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const view = initialView(selection);
const [dimensions, setDimensions] = useState<AssetAnalysisDimension[]>(() => getDefaultDimensions(view));
const [filterValues, setFilterValues] = useState<AnalysisFilterValues>({});
const [sort, setSort] = useState<AnalysisSort>('count-desc');
const [mergeParents, setMergeParents] = useState(true);
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
const showDetails = detailGroup !== null;
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
const [detailBusy, setDetailBusy] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailRetry, setDetailRetry] = useState(0);
const [search, setSearch] = useState('');
const request = useMemo(() => buildVehicleModalRequest(selection, subject), [selection, subject]);
const endpoint = `/api/vehicles/analysis${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
const available = getAvailableDimensions(view);
const analysis = useMemo(() => getCustomAnalysis(data?.cells ?? [], { view, dimensions, filterValues, sort }), [data, view, dimensions, filterValues, sort]);
const filterValuesKey = JSON.stringify(filterValues);
const detailFiltersKey = JSON.stringify(detailGroup?.values ?? {});
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setBusy(true); setError('');
const load = request.kind === 'weekly'
? fetchWeeklyDetail(request.type, { ...request.filters, subject }).then(value => { if (!cancelled) setWeekly(value); })
: fetchJson<AssetAnalysisResponse>(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: request.params }), signal: controller.signal }).then(value => { if (!cancelled) setData(value); });
load.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : '统计加载失败'); })
.finally(() => { if (!cancelled) setBusy(false); });
return () => { cancelled = true; controller.abort(); };
}, [request, subject, endpoint, retry]);
useEffect(() => {
if (!showDetails || request.kind !== 'vehicles' || !data) return;
const controller = new AbortController();
let cancelled = false;
setDetailBusy(true); setDetailError(''); setVehicles([]); setSearch('');
fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
body: JSON.stringify({ metric: analysis.metric, filters: JSON.parse(detailFiltersKey), filterValues: JSON.parse(filterValuesKey), generatedAt: data.generatedAt, scope: request.params }),
}).then(value => { if (!cancelled) setVehicles(value); })
.catch(e => { if (!cancelled) setDetailError(e instanceof Error ? e.message : '车辆明细加载失败'); })
.finally(() => { if (!cancelled) setDetailBusy(false); });
return () => { cancelled = true; controller.abort(); };
}, [showDetails, analysis.metric, detailFiltersKey, filterValuesKey, request, data, endpoint, detailRetry]);
const visibleVehicles = useMemo(() => {
const query = search.toLocaleLowerCase();
return vehicles.filter(vehicle => !query || [vehicle.plateNumber, vehicle.vin, vehicle.model, vehicle.city].some(value => value?.toLocaleLowerCase().includes(query)));
}, [vehicles, search]);
const detailProgress = useProgressiveRows({ total: visibleVehicles.length, rootRef: scrollRoot, resetKey: visibleVehicles, enabled: showDetails && !detailBusy && !busy, batchSize: 40 });
const shownVehicles = useMemo(() => visibleVehicles.slice(0, detailProgress.visibleCount), [visibleVehicles, detailProgress.visibleCount]);
const updateDimensions = (next: AssetAnalysisDimension[]) => { setDimensions(next); };
const updateFilters = (next: AnalysisFilterValues) => { setFilterValues(next); };
const reset = () => { updateDimensions(getDefaultDimensions(view)); updateFilters({}); setMergeParents(true); };
const narrowToGroup = (row: CustomAnalysisRow) => {
updateFilters({ ...filterValues, ...Object.fromEntries(Object.entries(row.values).map(([dimension, value]) => [dimension, [value]])) });
};
const merged = mergeParents && analysis.dimensions.length > 1;
const orderedGroups = useMemo(() => merged
? orderDimensionGroups(analysis.rows, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count, sort, (a, b, index) => compareCustomLabels(a, b, [analysis.dimensions[index]]))
: analysis.rows, [analysis, merged, sort]);
const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]);
const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]);
const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40 });
const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]);
const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]);
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title;
const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · ');
return <div className="asset-analysis-overlay">
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} /> : <>
{showDetails && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
<div ref={scrollRoot} className="analysis-scroll">
{!showDetails && <>
<AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options">{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
<tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{analysis.dimensions.length ? analysis.dimensions.map((dimension, columnIndex) => {
const span = merged ? spans[rowIndex][columnIndex] : 1;
return span === 0 ? null : <td key={dimension} rowSpan={span} className={span > 1 ? 'analysis-parent-cell' : undefined} data-label={DIMENSION_LABELS[dimension]}><Blur>{getDimensionValueLabel(dimension, row.values[dimension] || '')}</Blur>{merged && columnIndex < analysis.dimensions.length - 1 && childCounts[rowIndex][columnIndex] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][columnIndex])}</strong> 台</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[analysis.dimensions[columnIndex + 1]]}:<strong>{number(childCounts[rowIndex][columnIndex])}</strong> {dimensionCountUnit(analysis.dimensions[columnIndex + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td>
{view === 'inventory' && <><td className={`analysis-numeric ${row.totals.pending ? 'analysis-pending-count' : 'analysis-zero-count'}`} data-label="待交车(台)">{number(row.totals.pending)}</td><td className={`analysis-numeric ${row.totals.abnormal ? 'analysis-abnormal-count' : 'analysis-zero-count'}`} data-label="异动(台)">{number(row.totals.abnormal)}</td></>}
<td className="analysis-share-cell" data-label="占比"><span>{row.share.toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${row.share}%` }} /></span></td>
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => setDetailGroup(row)}>查看明细</button></td>
</tr>)}</tbody>
</table></div>
<div className="analysis-table-total"><span>合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</>}
</>}
{showDetails && <div className="analysis-details">
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
<ProgressiveRowsStatus {...detailProgress} unit="台" />
</>}
</div>}
</div>
<footer className="analysis-footer"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
</>}
</div>
</div>;
}
function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) {
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
const stateLabel = (vehicle: VehicleListItem) => {
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
return [business, operation].filter(Boolean).join(' · ');
};
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td><td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
}
type WeeklyDimension = 'date' | 'contract_type';
const WEEKLY_DIMENSIONS: WeeklyDimension[] = ['date', 'contract_type'];
const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = [];
const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date'
? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || '';
function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
type Dimension = WeeklyDimension;
const dimensionsAvailable = WEEKLY_DIMENSIONS;
const scrollRoot = useRef<HTMLDivElement>(null);
const labels: Record<Dimension, string> = { date: '交还日期', contract_type: '合同类型' };
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
const [filters, setFilters] = useState<Partial<Record<Dimension, string[]>>>({});
const [detailKey, setDetailKey] = useState<string | null>(null);
const value = weeklyDimensionValue;
const label = (dimension: Dimension, entry: string) => entry || (dimension === 'date' ? '日期缺失' : '未标注');
const filtered = useMemo(() => rows.filter(row => dimensionsAvailable.every(dimension => !filters[dimension]?.length || filters[dimension]!.includes(value(row, dimension)))), [rows, filters]);
const grouped = useMemo(() => {
const result = new Map<string, { key: string; values: string[]; rows: WeeklyDetailItem[] }>();
for (const row of filtered) {
const values = dimensions.map(dimension => value(row, dimension));
const key = JSON.stringify(values);
const group = result.get(key) ?? { key, values, rows: [] };
group.rows.push(row);
result.set(key, group);
}
return result;
}, [filtered, dimensions]);
const groups = useMemo(() => {
const flatGroups = [...grouped.values()].sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
return mergeActive ? orderDimensionGroups(flatGroups, dimensions.length, group => group.values, group => group.rows.length) : flatGroups;
}, [grouped, mergeActive, dimensions.length]);
const detailGroup = detailKey === null ? null : grouped.get(detailKey);
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS;
const showDetails = detailKey !== null;
const groupLabel = (group: { values: string[] }) => dimensions.length
? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ')
: '当前筛选结果';
const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]);
const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails });
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
const groupSpans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
const shownDetails = useMemo(() => detailRows.slice(0, detailProgress.visibleCount), [detailRows, detailProgress.visibleCount]);
const updateFilters = (next: Partial<Record<Dimension, string[]>>) => { setFilters(next); setDetailKey(null); };
const setFilter = (dimension: Dimension, selected: string[] | undefined) => {
const next = { ...filters };
if (selected === undefined) delete next[dimension]; else next[dimension] = selected;
updateFilters(next);
};
const updateDimensions = (next: Dimension[]) => { setDimensions(next); setDetailKey(null); };
const activeFilters = dimensionsAvailable.filter(dimension => filters[dimension]?.length);
const filterOptions = useMemo(() => Object.fromEntries(dimensionsAvailable.map(dimension => {
const eligible = rows.filter(row => dimensionsAvailable.every(other => other === dimension || !filters[other]?.length || filters[other]!.includes(value(row, other))));
const counts = new Map<string, number>();
for (const row of eligible) { const entry = value(row, dimension); counts.set(entry, (counts.get(entry) || 0) + 1); }
for (const entry of filters[dimension] || []) if (!counts.has(entry)) counts.set(entry, 0);
return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
})), [rows, filters]);
return <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <>
<div className="analysis-builder">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点击名称分组,点击箭头筛选</span><button type="button" className="analysis-reset" onClick={() => { updateDimensions(['date']); updateFilters({}); setMergeParents(true); }}>恢复默认</button></div>
<div className="adp-row" role="group" aria-label="交还车统计维度">
{dimensionsAvailable.map(dimension => {
const options = filterOptions[dimension];
return <DimensionPicker key={dimension} label={labels[dimension]} grouped={dimensions.includes(dimension)} order={dimensions.indexOf(dimension) + 1} options={options} selectedValues={filters[dimension]} onToggleGroup={() => updateDimensions(dimensions.includes(dimension) ? dimensions.filter(item => item !== dimension) : [...dimensions, dimension])} onApplyFilter={selected => setFilter(dimension, selected)} />;
})}
<button type="button" className="analysis-button" aria-pressed={dimensions.length === 0} onClick={() => updateDimensions([])}>仅筛选</button>
{activeFilters.length > 0 && <button type="button" className="analysis-text-button" onClick={() => updateFilters({})}>清空筛选</button>}
</div>
</div>
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
{groups.length ? <>
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{dimensions.length ? dimensions.map((dimension, index) => {
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
return span === 0 ? null : <td data-label={labels[dimension]} key={dimension} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'analysis-parent-cell' : undefined}>{label(dimension, group.values[index])}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][index])}</strong> 车次</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{labels[dimensions[index + 1]]}:<strong>{number(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(车次)"><strong>{number(group.rows.length)}</strong></td><td className="analysis-share-cell" data-label="占比"><span>{(group.rows.length / filtered.length * 100).toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${group.rows.length / filtered.length * 100}%` }} /></span></td>
<td className="analysis-group-actions">{dimensions.length > 0 && <button type="button" aria-label={`筛选分组 ${groupLabel(group)}`} onClick={() => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, index) => [dimension, [group.values[index]]])) })}><Filter size={12} />筛选此组</button>}<button type="button" aria-label={`查看明细 ${groupLabel(group)}`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
</tr>)}
</tbody></table></div>
<div className="analysis-table-total"><span>合计 {number(filtered.length)} 车次 · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
</> : <>
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3></div>
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td><Blur>{row.plate_number || '—'}</Blur></td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td><Blur>{row.customer_name || '—'}</Blur></td></tr>)}</tbody></table></div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
</>}
</div>;
}