diff --git a/src/modules/assets/assistant/assistant.css b/src/modules/assets/assistant/assistant.css
index f9c011d..2aa0560 100644
--- a/src/modules/assets/assistant/assistant.css
+++ b/src/modules/assets/assistant/assistant.css
@@ -396,15 +396,6 @@
font-size: 11px;
margin-top: 5px;
}
-.aq-welcome-note {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- font-size: 11px;
- color: var(--aq-muted);
- margin-top: 24px;
-}
.aq-turns {
max-width: 820px;
margin: 0 auto;
@@ -1134,10 +1125,6 @@
padding: 14px;
min-height: 110px;
}
- .aq-welcome-note {
- font-size: 10px;
- margin-top: 18px;
- }
.aq-turns {
padding: 19px 16px 24px;
}
@@ -1298,9 +1285,6 @@
.aq-prompt-grid > button > div {
margin-bottom: 8px;
}
- .aq-welcome-note {
- margin-top: 14px;
- }
}
/* Keep the assistant above the global feedback launcher, below the account watermark. */
.aq-shell {
@@ -2090,8 +2074,6 @@
.aq-prompt-grid > button > div > svg:last-child { display: none; }
.aq-prompt-grid > button strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.aq-prompt-grid > button > span { display: none; }
- .aq-welcome-note { margin-top: 12px; font-size: 12px; line-height: 1.5; flex-wrap: nowrap; }
- .aq-welcome-note svg { flex-shrink: 0; }
}
@media (prefers-reduced-motion: reduce) {
.aq-page .aq-spin, .aq-page .aq-shimmer, .aq-page .aq-trace li { animation: none; }
diff --git a/src/modules/assets/assistant/result-presentation.test.ts b/src/modules/assets/assistant/result-presentation.test.ts
index ae48151..72ee095 100644
--- a/src/modules/assets/assistant/result-presentation.test.ts
+++ b/src/modules/assets/assistant/result-presentation.test.ts
@@ -138,7 +138,7 @@ test("copy includes regional facts and daily flow without duplicating groups or
};
const regionalText = copyResultText(regional, null);
assert.match(regionalText, /华东:99台,占比73.3%/);
- assert.match(regionalText, /其中异动1台,其中待交车0台/);
+ assert.match(regionalText, /异动1台,待交车0台/);
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
assert.ok(!regionalText.includes("测试车牌不应复制"));
diff --git a/src/modules/assets/assistant/result-presentation.ts b/src/modules/assets/assistant/result-presentation.ts
index 229f6bb..f4b5ef9 100644
--- a/src/modules/assets/assistant/result-presentation.ts
+++ b/src/modules/assets/assistant/result-presentation.ts
@@ -137,7 +137,7 @@ export function copyResultText(
"分组统计:",
...result.groups.map(
(group) =>
- `${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,其中异动${group.abnormal}台`}${group.pending === undefined ? "" : `,其中待交车${group.pending}台`}`,
+ `${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,异动${group.abnormal}台`}${group.pending === undefined ? "" : `,待交车${group.pending}台`}`,
),
]
: []),
diff --git a/src/modules/assets/components/AbnormalVehicleDetails.tsx b/src/modules/assets/components/AbnormalVehicleDetails.tsx
index ed55754..087321f 100644
--- a/src/modules/assets/components/AbnormalVehicleDetails.tsx
+++ b/src/modules/assets/components/AbnormalVehicleDetails.tsx
@@ -5,11 +5,13 @@ type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
- const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '具体工作', '申请人', '目标完成时间', '实际完成时间'];
+ const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '审批通过时间', '已异动时长', '具体工作', '申请人', '预计完成时间', '实际完成时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [
vehicle.abnormalMove?.type || '未提供',
+ vehicle.abnormalMove?.approvalPassedAt ? new Date(vehicle.abnormalMove.approvalPassedAt).toLocaleString('zh-CN', { hour12: false }) : '审批时间缺失',
+ vehicle.abnormalMove?.elapsedDays != null ? `${vehicle.abnormalMove.elapsedDays.toFixed(1)} 天` : '无法计算',
vehicle.abnormalMove?.work || '未提供',
vehicle.abnormalMove?.applicant || '未提供',
vehicle.abnormalMove?.targetCompletionTime || '未提供',
diff --git a/src/modules/assets/components/FlowAnalysisModal.tsx b/src/modules/assets/components/FlowAnalysisModal.tsx
new file mode 100644
index 0000000..ebf80e2
--- /dev/null
+++ b/src/modules/assets/components/FlowAnalysisModal.tsx
@@ -0,0 +1,159 @@
+import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
+import { ArrowLeft, Download, X } from 'lucide-react';
+import type { FlowDetailItem, FlowType } from '../api';
+import type { FlowSelection } from '../model';
+import { FLOW_META } from './FlowDetailModal';
+import { orderDimensionGroups, dimensionRowSpans, dimensionParentTotals, dimensionChildCounts, dimensionCountUnit, visibleDimensionRowSpans } from '../dimension-layout';
+import { useDialogFocus } from './useDialogFocus';
+import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
+import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
+import { useProgressiveRows } from './analysis/useProgressiveRows';
+import './flow-analysis-modal.css';
+
+interface FlowAnalysisModalProps {
+ selectedFlow: FlowSelection | null;
+ selectedFlowDetails: FlowDetailItem[];
+ setSelectedFlow: Dispatch
>;
+ exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
+}
+
+type Dimension = 'date' | 'department' | 'manager' | 'type';
+const DIMENSIONS: { key: Dimension; label: string }[] = [
+ { key: 'date', label: '日期' }, { key: 'department', label: '部门' },
+ { key: 'manager', label: '业务负责人' }, { key: 'type', label: '业务类型' },
+];
+const DIMENSION_LABELS: Record = { date: '日期', department: '部门', manager: '业务负责人', type: '业务类型' };
+const EMPTY_FLOW_ROWS: FlowDetailItem[] = [];
+const countLabel = (count: number) => count.toLocaleString('zh-CN');
+const dimensionValue = (row: FlowDetailItem, dimension: Dimension) => row[dimension].trim();
+const dimensionLabel = (dimension: Dimension, value: string) => dimension === 'type'
+ ? FLOW_META[value as FlowType]?.label || value
+ : value || `未填写${DIMENSION_LABELS[dimension]}`;
+
+interface FlowGroup { key: string; values: string[]; rows: FlowDetailItem[] }
+
+/** Each business event belongs to exactly one actual type; replacements also belong to the delivery scope. */
+function matchesType(row: FlowDetailItem, type: FlowType): boolean {
+ return row.type === type || (type === 'delivered' && row.type === 'replaced');
+}
+
+function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[] {
+ const groups = new Map();
+ for (const row of rows) {
+ const values = dimensions.map(dimension => dimensionValue(row, dimension));
+ const key = JSON.stringify(values);
+ const group = groups.get(key) ?? { key, values, rows: [] };
+ group.rows.push(row);
+ groups.set(key, group);
+ }
+ return Array.from(groups.values()).sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
+}
+
+export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
+ if (!props.selectedFlow) return null;
+ return ;
+}
+
+function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow, exportFlowDetails }: Omit & { selectedFlow: FlowSelection }) {
+ const [dimensions, setDimensions] = useState(['date']);
+ const [mergeParents, setMergeParents] = useState(true);
+ const mergeActive = mergeParents && dimensions.length > 1;
+ const [filters, setFilters] = useState>>({});
+ const [detailKey, setDetailKey] = useState(null);
+ const dialog = useRef(null);
+ const scrollRoot = useRef(null);
+ const close = useCallback(() => setSelectedFlow(null), [setSelectedFlow]);
+ useDialogFocus(true, dialog, close);
+
+ const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
+ && ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)), [selectedFlowDetails, selectedFlow]);
+ const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
+ const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
+ const counts = new Map();
+ for (const row of eligible) { const value = dimensionValue(row, key); counts.set(value, (counts.get(value) || 0) + 1); }
+ for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
+ return [key, [...counts].map(([value, count]) => ({ value, label: dimensionLabel(key, value), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
+ })) as Record, [rows, filters]);
+ const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
+ const groups = useMemo(() => {
+ const flat = groupRows(filteredRows, dimensions);
+ return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length) : flat;
+ }, [filteredRows, dimensions, mergeActive]);
+ const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
+ const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
+ const showDetails = detailKey !== null;
+ const detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
+ const selectedRows = showDetails ? detailRows : filteredRows;
+ const { replacedCount, departmentCount } = useMemo(() => ({
+ replacedCount: selectedRows.reduce((sum, row) => sum + Number(row.type === 'replaced'), 0),
+ departmentCount: new Set(selectedRows.map(row => dimensionValue(row, 'department'))).size,
+ }), [selectedRows]);
+ const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`;
+ 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 activeFilters = DIMENSIONS.filter(({ key }) => filters[key]?.length);
+ const resetResult = () => { setDetailKey(null); };
+ const toggleDimension = (dimension: Dimension) => {
+ setDimensions(current => current.includes(dimension) ? current.filter(value => value !== dimension) : [...current, dimension]);
+ resetResult();
+ };
+ const changeFilter = (dimension: Dimension, value: string[] | undefined) => {
+ setFilters(current => { const next = { ...current }; if (value === undefined) delete next[dimension]; else next[dimension] = value; return next; });
+ resetResult();
+ };
+
+ return { if (event.target === event.currentTarget) close(); }}>
+
+
+
+
+ {!showDetails ?
+
分析维度
+ {DIMENSIONS.map(({ key, label }) => toggleDimension(key)} onApplyFilter={values => changeFilter(key, values)} />)}
+
+ {activeFilters.length ? : null}
+
+
点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。
+
:
{detailLabel} }
+
+
+
{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}{countLabel(selectedRows.length)}车次
+
涉及部门{departmentCount}个
+ {selectedFlow.type === 'delivered' ?
其中:替换交车{countLabel(replacedCount)}车次
:
{showDetails ? '占当前筛选' : '统计分组'}{showDetails ? `${filteredRows.length ? (selectedRows.length / filteredRows.length * 100).toFixed(1) : '0.0'}%` : groups.length}{!showDetails ? 组 : null}
}
+
+
+
{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}
{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}
+ {!showDetails && dimensions.length > 1 ?
: null}
+ {showDetails ?
: null}
+
+
+ {!showDetails ? groups.length ? <>
+
{dimensions.length ? dimensions.map(dimension => | {DIMENSION_LABELS[dimension]} | ) : 范围 | }车次 | 占比 | 操作 |
+ {shownGroups.map((group, rowIndex) => 0 ? 'bi-flow-modal-group-start' : undefined}>
+ {dimensions.length ? group.values.map((value, index) => {
+ const span = mergeActive ? groupSpans[rowIndex][index] : 1;
+ return span === 0 ? null : | 1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <>合计 {countLabel(parentTotals[rowIndex][index])} 车次{DIMENSION_LABELS[dimensions[index + 1]]}:{countLabel(childCounts[rowIndex][index])} {dimensionCountUnit(dimensions[index + 1])}>} | ;
+ }) : 当前筛选范围 | }
+ {countLabel(group.rows.length)} | {(group.rows.length / filteredRows.length * 100).toFixed(1)}% |
+ |
+
)}
+
+
+ > :
当前筛选范围暂无交还车数据,请调整筛选。
+ : detailRows.length ? <>
+
| 车牌 | 业务类型 | 运维签章时间 | 建单时间 | 部门 | 业务负责人 | 客户 |
{shownDetails.map(item => | {item.plateNumber || '未登记车牌'} | {FLOW_META[item.type].label} | {item.eventTime || '—'} | {item.submitTime || '—'} | {dimensionLabel('department', dimensionValue(item, 'department'))} | {item.manager || '—'} | {item.customerName || '—'} |
)}
+
+ > :
当前分组暂无业务明细,请返回统计调整条件。
}
+
+
+
;
+}
diff --git a/src/modules/assets/components/FlowDetailModal.tsx b/src/modules/assets/components/FlowDetailModal.tsx
index 5935a00..e191f02 100644
--- a/src/modules/assets/components/FlowDetailModal.tsx
+++ b/src/modules/assets/components/FlowDetailModal.tsx
@@ -46,7 +46,7 @@ export function FlowDetailModal({
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
- 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
+ 共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户