feat(assets): release v1.1.18 with two-sheet analysis exports and flow contract details
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-28 17:03:59 +08:00
parent 807453b5fd
commit 40ef05d49b
18 changed files with 212 additions and 25 deletions
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { ArrowLeft, Download, X } from 'lucide-react';
import { ArrowLeft, X } from 'lucide-react';
import type { FlowDetailItem, FlowType } from '../api';
import type { FlowSelection } from '../model';
import { FLOW_META } from './FlowDetailModal';
@@ -8,13 +8,15 @@ import { useDialogFocus } from './useDialogFocus';
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
import { useProgressiveRows } from './analysis/useProgressiveRows';
import { AnalysisExportButton } from './analysis/AnalysisExportButton';
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
import './analysis/asset-analysis.css';
import './flow-analysis-modal.css';
interface FlowAnalysisModalProps {
selectedFlow: FlowSelection | null;
selectedFlowDetails: FlowDetailItem[];
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
}
type Dimension = 'date' | 'department' | 'manager' | 'type';
@@ -54,7 +56,7 @@ export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
}
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow, exportFlowDetails }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
const [mergeParents, setMergeParents] = useState(true);
const mergeActive = mergeParents && dimensions.length > 1;
@@ -108,6 +110,14 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
resetResult();
};
const exportWorkbook = () => {
const statistics = statisticsExportRows(dimensions.map(d => DIMENSION_LABELS[d]), (detailGroup ? [detailGroup] : groups).map(group => ({ values: group.values.map((v, i) => dimensionLabel(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
const details = selectedRows.map(item => ({ 业务日期: item.date, 类型: item.typeLabel, 车牌: item.plateNumber,
运维签章时间: item.eventTime, 建单时间: item.submitTime, 部门: item.department, 业务负责人: item.manager,
客户: item.customerName, 合同: item.contractNo, 项目名称: item.projectName }));
return downloadAnalysisWorkbook(statistics, details, `${FLOW_META[selectedFlow.type].label}-${rangeLabel}-${showDetails ? detailLabel : '当前筛选范围'}`);
};
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
<header className="bi-flow-modal-header">
@@ -133,7 +143,9 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
{showDetails ? <button type="button" disabled={!detailRows.length} className="bi-flow-modal-export" onClick={() => exportFlowDetails(detailRows, `${FLOW_META[selectedFlow.type].label}-${detailLabel}-明细`)}><Download size={13} />导出当前明细</button> : null}
<div className="analysis-export-actions">
<AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />
</div>
</div>
{!showDetails ? groups.length ? <>
@@ -150,7 +162,7 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
: detailRows.length ? <>
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td></tr>)}</tbody></table></div>
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
</div>
@@ -0,0 +1,23 @@
import { useRef, useState } from 'react';
import { Download, Loader2 } from 'lucide-react';
export function AnalysisExportButton({ disabled, onExport }: {
disabled?: boolean; onExport: () => void | Promise<void>;
}) {
const running = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const run = async () => {
if (running.current) return;
running.current = true; setBusy(true); setError('');
try { await onExport(); }
catch { setError('导出失败,请重试'); }
finally { running.current = false; setBusy(false); }
};
return <span className="analysis-export-action">
<button type="button" className="analysis-button" disabled={disabled || busy} onClick={run}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />}{busy ? '正在导出…' : '导出 Excel'}
</button>
{error && <small role="alert">{error}</small>}
</span>;
}
@@ -14,6 +14,8 @@ import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api';
import type { VehicleListItem } from '../../types';
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
import { useDialogFocus } from '../useDialogFocus';
import { AnalysisExportButton } from './AnalysisExportButton';
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
import './asset-analysis.css';
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
@@ -111,6 +113,23 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
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(' · ');
const exportWorkbook = async () => {
const rows = showDetails ? visibleVehicles : await fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: analysis.metric, filters: {}, filterValues, generatedAt: data?.generatedAt, scope: request.kind === 'vehicles' ? request.params : {} }),
});
const exportGroups = detailGroup ? [{ ...detailGroup, count: rows.length, totals: {
pending: rows.filter(v => v.status === 'Pending').length,
abnormal: rows.filter(v => v.status === 'Abnormal').length,
} }] : orderedGroups;
const statistics = statisticsExportRows(
analysis.dimensions.map(d => DIMENSION_LABELS[d]),
exportGroups.map(row => ({ values: analysis.dimensions.map(d => getDimensionValueLabel(d, row.values[d] || '')), count: row.count,
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
showDetails ? rows.length : analysis.total,
);
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
};
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>
@@ -122,7 +141,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
<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>
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{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>
@@ -142,7 +161,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
</>}
</>}
{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>
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><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} />}
@@ -230,6 +249,13 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
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]);
const exportWorkbook = () => {
const selectedRows = showDetails ? detailRows : filtered;
const selectedGroups = detailGroup ? [detailGroup] : groups;
const statistics = statisticsExportRows(dimensions.map(d => labels[d]), selectedGroups.map(group => ({ values: group.values.map((v, i) => label(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
};
return <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <>
<div className="analysis-builder">
@@ -244,7 +270,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
</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>
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p><div className="analysis-export-actions"><AnalysisExportButton disabled={!filtered.length} onExport={exportWorkbook} /></div>{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}>
@@ -261,7 +287,7 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
</> : <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>
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></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>}
@@ -170,3 +170,8 @@
/* Keep a one-pixel grid; darker boundaries separate parent groups. */
.analysis-group-table tbody tr:first-child > td,
.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); }
.analysis-export-action { display:inline-flex; flex-direction:column; gap:3px; flex-shrink:0; }
.analysis-export-action button:disabled { opacity:.45; cursor:not-allowed; }
.analysis-export-action small { color:#b91c1c; font-size:11px; }
.analysis-export-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
@@ -57,7 +57,8 @@
.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; }
.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; }
.bi-flow-modal-action button:hover { text-decoration: underline; }
.bi-flow-modal-detail-table { min-width: 800px; }
.bi-flow-modal-detail-table { min-width: 1120px; }
.bi-flow-modal-detail-table td:nth-child(8), .bi-flow-modal-detail-table td:nth-child(9) { min-width: 160px; overflow-wrap: anywhere; }
.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; }
.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; }
.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; }