From 0a32392215c9152f169ccfd097b248bd9ccae834 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 16:00:59 +0800 Subject: [PATCH 1/3] feat(assets): refine inventory presentation and abnormal vehicle details --- src/modules/assets/AssetsModule.tsx | 20 +- .../components/AbnormalVehicleDetails.tsx | 26 +- .../assets/components/VehicleDetailModal.tsx | 2 +- .../overview/InventoryDesktopTable.tsx | 238 +++++--------- .../overview/InventoryMobileList.tsx | 153 ++------- .../overview/InventoryModelTags.tsx | 11 + .../overview/InventoryStatisticsSection.tsx | 294 ++++++------------ .../overview/inventory-presentation.test.ts | 109 +++++++ .../overview/inventory-presentation.ts | 93 ++++++ .../overview/inventory-statistics.css | 175 +++++++++++ .../routes/vehicles/abnormal-move.test.ts | 8 + src/server/routes/vehicles/model.ts | 1 + src/server/routes/vehicles/repository.ts | 2 + src/server/types.ts | 1 + src/shared/assets/abnormal-move.ts | 2 + 15 files changed, 652 insertions(+), 483 deletions(-) create mode 100644 src/modules/assets/components/overview/InventoryModelTags.tsx create mode 100644 src/modules/assets/components/overview/inventory-presentation.test.ts create mode 100644 src/modules/assets/components/overview/inventory-presentation.ts create mode 100644 src/modules/assets/components/overview/inventory-statistics.css diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 7b9fb8b..fc3a8c7 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -316,17 +316,21 @@ export default function AssetsModule() { }; const toggleInventoryRegion = (region: string) => { - const newSet = new Set(expandedInventoryRegions); - if (newSet.has(region)) newSet.delete(region); - else newSet.add(region); - setExpandedInventoryRegions(newSet); + setExpandedInventoryRegions(current => { + const next = new Set(current); + if (next.has(region)) next.delete(region); + else next.add(region); + return next; + }); }; const toggleInventoryType = (type: string) => { - const newSet = new Set(expandedInventoryTypes); - if (newSet.has(type)) newSet.delete(type); - else newSet.add(type); - setExpandedInventoryTypes(newSet); + setExpandedInventoryTypes(current => { + const next = new Set(current); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); }; const inventoryTypeFilter = isInventoryFilterOpen diff --git a/src/modules/assets/components/AbnormalVehicleDetails.tsx b/src/modules/assets/components/AbnormalVehicleDetails.tsx index 74dc0c7..ed55754 100644 --- a/src/modules/assets/components/AbnormalVehicleDetails.tsx +++ b/src/modules/assets/components/AbnormalVehicleDetails.tsx @@ -5,38 +5,44 @@ 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?.targetCompletionTime || '未提供']; + : [ + vehicle.abnormalMove?.type || '未提供', + vehicle.abnormalMove?.work || '未提供', + vehicle.abnormalMove?.applicant || '未提供', + vehicle.abnormalMove?.targetCompletionTime || '未提供', + vehicle.abnormalMove?.actualCompletionTime || (vehicle.abnormalMove?.status === '待结束' ? '未完成' : '未提供'), + ]; if (vehicles.length === 0) return
暂无符合筛选条件的{pending ? '待交车' : '异动车辆'}
; return <> -

{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '展示当前异动类型与预计结束时间。'}

+

{pending ? '展示当前待交车任务的合同信息,客户名称已脱敏。' : '申请人取异动单创建人;未结束的异动显示“未完成”,缺失信息显示“未提供”。'}

{vehicles.map(vehicle =>

{vehicle.plateNumber || vehicle.vin || '—'}

{vehicle.brandLabel || '—'} · {vehicle.type} · {vehicle.location === '其他' ? '对接中' : vehicle.location}

-
- {details(vehicle).map((value, index) =>
+
+ {details(vehicle).map((value, index) =>
{labels[index]}
-
{value}
+
{value}
)}
)}
-
- +
+
- {['车牌', '品牌', '车型', '所在地', ...labels].map(label => )} + {['车牌', '品牌', '车型', '所在地', ...labels].map(label => )} {vehicles.map(vehicle => - {details(vehicle).map((value, index) => )} + {details(vehicle).map((value, index) => )} )}
{label}{label}
{vehicle.plateNumber || vehicle.vin || '—'} {vehicle.brandLabel || '—'} {vehicle.type} {vehicle.location === '其他' ? '对接中' : vehicle.location}{value}{value}
diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index fb95497..552b157 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -54,7 +54,7 @@ export function VehicleDetailModal({ ref={dialogRef} role="dialog" aria-modal="true" aria-label={showPlateNumbers.title || "车辆明细"} tabIndex={-1} initial={{ scale: 0.9, opacity: 0, y: 20 }} animate={{ scale: 1, opacity: 1, y: 0 }} - className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`} + className={`bg-white rounded-xl shadow-2xl w-full max-w-[95vw] ${showPlateNumbers.source === 'customer' || showPlateNumbers.category === 'Abnormal' ? 'lg:max-w-6xl' : 'lg:max-w-4xl'} overflow-hidden flex flex-col max-h-[85vh] sm:max-h-[90vh] min-h-[40vh]`} >
diff --git a/src/modules/assets/components/overview/InventoryDesktopTable.tsx b/src/modules/assets/components/overview/InventoryDesktopTable.tsx index 601abb2..311982b 100644 --- a/src/modules/assets/components/overview/InventoryDesktopTable.tsx +++ b/src/modules/assets/components/overview/InventoryDesktopTable.tsx @@ -1,158 +1,90 @@ +import { Fragment, type Dispatch, type SetStateAction } from 'react'; +import { ArrowUpRight, ChevronRight } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import React from 'react'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; -import type { InventoryStatisticsSectionProps } from './types'; +import { InventoryModelTags } from './InventoryModelTags'; +import { inventoryBrandGroups, inventorySelection, type InventoryDisplayGroup } from './inventory-presentation'; +import type { InventoryFilters, VehicleModalSelection } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; -type InventoryDesktopTableProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; +export interface InventoryListProps { + groups: InventoryDisplayGroup[]; + tab: 'region' | 'model'; + expanded: Set; + toggle: (name: string) => void; + total: number; + filters: InventoryFilters; + select: Dispatch>; +} -export function InventoryDesktopTable({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryDesktopTableProps) { - return ( -
- - - - - - - - - - - {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( - - toggleInventoryRegion(region)} - > - - - - {expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - ))} - - ))} - - - )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( - - toggleInventoryType(type)} - > - - - - {expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => ( - - {stats.map((stat, idx) => ( - - - - - - - ))} - - ))} - - - )) - )} +export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) { + return ; +} + +export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) { + select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`)); +} + +export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick) { + select(inventorySelection(filters, `库存统计 · ${group.name}`, tab === 'region' + ? { location: filters.city || group.name } + : { vehicleType: group.name })); +} + +export function InventoryShare({ quantity, total }: { quantity: number; total: number }) { + const percent = total ? quantity / total * 100 : 0; + return
; +} + +function InventoryDetailRows({ rows, city, tab, total, select }: Pick & { rows: RegionalInventoryStats[]; city?: string }) { + const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model'); + return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => variants.map((row, index) => +
+ {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && } + {variantIndex === 0 && index === 0 && } + {index === 0 && } + + + + + ))); +} + +export function InventoryDesktopTable(props: InventoryListProps) { + const { groups, tab, expanded, toggle, total, select } = props; + return
+
{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}{inventoryTab === 'region' ? '品牌' : '品牌'}{inventoryTab === 'region' ? '车型' : '所在区域/城市'}库存数量
-
- {expandedInventoryRegions.has(region) ? : } - {region} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: region, category: 'Inventory', source: 'asset', title: `库存统计 - ${region}` }); - }}> - (共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {city} : ''} - {stat.brand}{stat.model} - -
-
-
- {expandedInventoryTypes.has(type) ? : } - {type} - { - e.stopPropagation(); - setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type: type, category: 'Inventory', source: 'asset', title: `库存统计 - ${type}` }); - }}> - (共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台) - - -
-
- {idx === 0 ? {model} : ''} - {stat.brand}{stat.region} / {stat.city} - -
-
{city || '未分配'}{brand.brand || '—'} + {tab === 'region' ? {row.type || '未分类'} : } + {tab === 'region' ? : <>{row.region}{row.city}} selectInventoryRow(row, select)} />
+ + + {tab === 'region' && } + + {tab === 'region' && } + + {tab === 'model' && } + + + + {groups.map(group => + + + + + -
按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数
区域 / 城市品牌车型类别 / 颜色 / 归属方区域 / 城市库存数量库存占比
+
+ + +
+
selectInventoryGroup(group, props)} />
-
- ); + {expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => + + )} + )} + +
; } diff --git a/src/modules/assets/components/overview/InventoryMobileList.tsx b/src/modules/assets/components/overview/InventoryMobileList.tsx index 3708293..235d4ba 100644 --- a/src/modules/assets/components/overview/InventoryMobileList.tsx +++ b/src/modules/assets/components/overview/InventoryMobileList.tsx @@ -1,122 +1,37 @@ +import { ChevronRight } from 'lucide-react'; import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { ChevronDown, ChevronRight } from 'lucide-react'; -import type { InventoryStatisticsSectionProps } from './types'; +import { InventoryModelTags } from './InventoryModelTags'; +import { inventoryBrandGroups } from './inventory-presentation'; +import { InventoryQuantity, InventoryShare, selectInventoryGroup, selectInventoryRow, type InventoryListProps } from './InventoryDesktopTable'; -type InventoryMobileListProps = Pick< - InventoryStatisticsSectionProps, - | 'inventoryTab' - | 'inventoryByRegion' - | 'expandedInventoryRegions' - | 'toggleInventoryRegion' - | 'inventoryByModel' - | 'expandedInventoryTypes' - | 'toggleInventoryType' - | 'setVehicleSelection' ->; - -export function InventoryMobileList({ - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryMobileListProps) { - return ( -
- {inventoryTab === 'region' ? ( - Object.entries(inventoryByRegion).map(([region, cities]) => ( -
-
toggleInventoryRegion(region)} - > -
- {expandedInventoryRegions.has(region) ? : } - {region} -
- - {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryRegions.has(region) && ( -
- {Object.entries(cities).map(([city, stats]) => ( -
-
{city}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.model} -
- - {(stat.abnormal || stat.pending) ?
: null} -
- ))} -
-
- ))} -
- )} -
- )) - ) : ( - Object.entries(inventoryByModel).map(([type, models]) => ( -
-
toggleInventoryType(type)} - > -
- {expandedInventoryTypes.has(type) ? : } - {type} -
- - {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台 - - -
- - {expandedInventoryTypes.has(type) && ( -
- {Object.entries(models).map(([model, stats]) => ( -
-
{model}
-
- {stats.map((stat, idx) => ( -
-
- {stat.brand} - {stat.region} / {stat.city} -
- - {(stat.abnormal || stat.pending) ?
: null} -
- ))} -
-
- ))} -
- )} -
- )) - )} -
- ); +export function InventoryMobileList(props: InventoryListProps) { + const { groups, tab, expanded, toggle, total, select } = props; + return
+ {groups.map(group =>
+
+ + selectInventoryGroup(group, props)} /> +
+
库存占比
+ {(group.rows.some(row => row.abnormal || row.pending)) &&
} + {expanded.has(group.name) &&
+ {(tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) =>
+ {tab === 'region' &&

{name || '未分配'}

} + {inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model').map(brand =>
+

{brand.brand || '未分配品牌'}

+ {brand.variants.map((variants, variantIndex) =>
+
{tab === 'region' ? {variants[0].type || '未分类'} : }
+ {variants.map((row, index) =>
+
{tab === 'region' ? : <>{row.region}{row.city}}
+ selectInventoryRow(row, select)} /> + {(row.abnormal || row.pending) ?
: null} +
)} +
)} +
)} +
)} +
} +
)} +
; } diff --git a/src/modules/assets/components/overview/InventoryModelTags.tsx b/src/modules/assets/components/overview/InventoryModelTags.tsx new file mode 100644 index 0000000..753ee22 --- /dev/null +++ b/src/modules/assets/components/overview/InventoryModelTags.tsx @@ -0,0 +1,11 @@ +import type { RegionalInventoryStats } from '../../types'; +import { inventoryModelTags } from './inventory-presentation'; + +export function InventoryModelTags({ row, hideType = false }: { row: Pick; hideType?: boolean }) { + const tags = inventoryModelTags(row).filter(tag => !hideType || tag.kind !== 'type'); + return + {tags.length ? tags.map(tag => + {tag.label}:{tag.value} + ) : '—'} + ; +} diff --git a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx index 35b62ce..ad0ca91 100644 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx @@ -1,211 +1,121 @@ -import { InventoryStatusCounts } from './InventoryStatusCounts'; -import { Filter } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; +import { useId, useRef, useState } from 'react'; +import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react'; import { InventoryDesktopTable } from './InventoryDesktopTable'; import { InventoryMobileList } from './InventoryMobileList'; +import { inventoryGroups, inventorySelection, inventoryTotal } from './inventory-presentation'; +import type { InventoryFilters } from '../../model'; import type { InventoryStatisticsSectionProps } from './types'; +import './inventory-statistics.css'; -export function InventoryStatisticsSection({ - inventoryFilters, - setInventoryFilters, - draftInventoryFilters, - setDraftInventoryFilters, - isInventoryFilterOpen, - setIsInventoryFilterOpen, - uniqueInventoryRegions, - uniqueInventoryCities, - uniqueInventoryBrands, - uniqueInventoryTypes, - uniqueInventoryModelsForType, - filteredInventoryStats, - inventoryTab, - setInventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, -}: InventoryStatisticsSectionProps) { - const listProps = { - inventoryTab, - inventoryByRegion, - expandedInventoryRegions, - toggleInventoryRegion, - inventoryByModel, - expandedInventoryTypes, - toggleInventoryType, - setVehicleSelection: setShowPlateNumbers, +const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' }; +const filterLabels: Record = { region: '区域', city: '城市', brand: '品牌', type: '车型', model: '型号 / 批次' }; + +export function InventoryStatisticsSection(props: InventoryStatisticsSectionProps) { + const { + inventoryFilters: filters, setInventoryFilters: setFilters, + draftInventoryFilters: draft, setDraftInventoryFilters: setDraft, + isInventoryFilterOpen: filterOpen, setIsInventoryFilterOpen: setFilterOpen, + filteredInventoryStats: rows, inventoryTab: tab, setInventoryTab: setTab, + setVehicleSelection: select, + } = props; + const id = useId(); + const filterTrigger = useRef(null); + const [sort, setSort] = useState<'default' | 'quantity'>('default'); + const total = inventoryTotal(rows); + const abnormal = rows.reduce((sum, row) => sum + (row.abnormal || 0), 0); + const pending = rows.reduce((sum, row) => sum + (row.pending || 0), 0); + const activeFilters = (Object.keys(filters) as (keyof InventoryFilters)[]).filter(key => filters[key]); + const groups = inventoryGroups(tab === 'region' ? props.inventoryByRegion : props.inventoryByModel, sort, tab); + const expanded = tab === 'region' ? props.expandedInventoryRegions : props.expandedInventoryTypes; + const toggle = tab === 'region' ? props.toggleInventoryRegion : props.toggleInventoryType; + const allExpanded = groups.length > 0 && groups.every(group => expanded.has(group.name)); + const closeFilters = () => { setFilterOpen(false); filterTrigger.current?.focus(); }; + const resetFilters = () => { setFilters({ ...emptyFilters }); setDraft({ ...emptyFilters }); }; + const options = { + region: props.uniqueInventoryRegions, city: props.uniqueInventoryCities, + brand: props.uniqueInventoryBrands, type: props.uniqueInventoryTypes, + model: props.uniqueInventoryModelsForType, }; + const listProps = { groups, tab, expanded, toggle, total, filters, select }; return ( -
-
-
-
-
-

库存统计

- 实时更新库存数据 -
+
+
+
+ +

库存统计

从区域与车型查看库存分布,点击数量查看车辆明细

+ +
-
-
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Inventory', source: 'asset', title: '库存总数' })}> - 总库存数据 - - {filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)} - 台 - -
- -
- - - - {isInventoryFilterOpen && ( - <> - -
-

库存统计 - 数据筛选

- -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
- - )} -
-
-
+
+ + +
+
{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市待交车、异动已包含在库存总数中
- {filteredInventoryStats.some(row => (row.abnormal || 0) > 0 || (row.pending || 0) > 0) && ( -
- - 已包含在库存总数中 + {filterOpen &&
{ if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }} + onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}> +
筛选库存
+
+ {(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => )}
- )} +
+ + + +
+
} -
-
- - )} + +
} + +
+
+ + +
+
+ +
- {/* Active Filters Bar */} - {(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && ( -
- {inventoryFilters.region && ( - - 区域: {inventoryFilters.region} - - - )} - {inventoryFilters.city && ( - - 城市: {inventoryFilters.city} - - - )} - {inventoryFilters.brand && ( - - 品牌: {inventoryFilters.brand} - - - )} - {inventoryFilters.type && ( - - 车型: {inventoryFilters.type} - - - )} - {inventoryFilters.model && ( - - 批次: {inventoryFilters.model} - - - )} - -
- )} - - {/* Desktop View Table */} - - - {/* Mobile View */} - -
+ {groups.length ? <> : +
} +
共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存占比基于当前筛选结果 · 单位:台
+
); } diff --git a/src/modules/assets/components/overview/inventory-presentation.test.ts b/src/modules/assets/components/overview/inventory-presentation.test.ts new file mode 100644 index 0000000..b3546d7 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-presentation.test.ts @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildVehicleModalRequest, groupInventoryByRegion } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; +import { inventoryBrandGroups, inventoryGroups, inventoryModelTags, inventorySelection } from './inventory-presentation'; + +const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' }; + +test('filtered inventory totals retain all filters in the vehicle request', () => { + const request = buildVehicleModalRequest(inventorySelection(filters, '筛选结果'), '公司甲'); + assert.deepEqual(request, { kind: 'vehicles', params: { + brand: '品牌甲', vehicleType: '18T', model: '批次一', location: '上海', + source: 'asset', category: 'Inventory', subject: '公司甲', + } }); +}); + +test('inventory subsets retain scope while changing category', () => { + for (const category of ['Pending', 'Abnormal'] as const) { + const request = buildVehicleModalRequest(inventorySelection({ ...filters, city: '' }, '子集', { category }), null); + assert.equal(request.kind, 'vehicles'); + if (request.kind !== 'vehicles') throw new Error('wrong request type'); + assert.equal(request.params.location, '华东'); + assert.equal(request.params.category, category); + assert.equal(request.params.vehicleType, '18T'); + assert.equal(request.params.brand, '品牌甲'); + } +}); + +test('quantity sorting preserves source order and counts inventory subsets only once', () => { + const rows: RegionalInventoryStats[] = [ + { ...filters, batch: '', quantity: 8, pending: 2, abnormal: 1 }, + { ...filters, city: '苏州', batch: '', quantity: 3 }, + { ...filters, region: '华南', city: '广州', batch: '', quantity: 20 }, + ]; + const source = groupInventoryByRegion(rows); + assert.deepEqual(inventoryGroups(source, 'quantity').map(group => [group.name, group.quantity]), [['华南', 20], ['华东', 11]]); + assert.deepEqual(inventoryGroups(source, 'default').map(group => group.name), ['华东', '华南']); + assert.deepEqual(inventoryGroups({}, 'quantity'), []); +}); + +test('model tags separate type and qualifiers without inventing a batch', () => { + assert.deepEqual(inventoryModelTags({ type: '4.5T冷链', model: '现代4.5T冷链(交投)', batch: '现代4.5T冷链(交投)' }), [ + { kind: 'type', label: '车型', value: '4.5T冷链' }, + { kind: 'detail', label: '型号说明', value: '交投' }, + ]); + assert.deepEqual(inventoryModelTags({ type: '18T', model: '飞驰18T(白车)', batch: '2026-01' }).map(tag => tag.value), ['18T', '白车', '2026-01']); + assert.deepEqual(inventoryModelTags({ type: '挂车', model: '挂车', batch: '挂车' }).map(tag => tag.value), ['挂车']); + assert.deepEqual(inventoryModelTags({ type: '', model: '未知型号(未闭合', batch: '' }).map(tag => tag.value), ['未知型号(未闭合']); +}); + +test('brand removal preserves body style, color and qualifiers in model tags', () => { + assert.deepEqual(inventoryModelTags({ brand: '帕力安牌', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '' }).map(tag => tag.value), ['18T', '双飞翼', '羚牛']); + assert.deepEqual(inventoryModelTags({ brand: '苏龙', type: '18T', model: '苏龙18T飞翼(白色)', batch: '' }).map(tag => tag.value), ['18T', '飞翼', '白色']); + assert.deepEqual(inventoryModelTags({ brand: '飞驰', type: '49T', model: '49T飞驰(红车)', batch: '' }).map(tag => tag.value), ['49T', '红车']); +}); + +test('brand ordering stays inside city blocks and does not mutate source rows', () => { + const brands = ['其他', '飞驰', '跃进', '帕力安牌', '宇通', '现代', '苏龙', '帕力安', '明威']; + const records = brands.map(brand => ({ ...filters, brand, batch: '', quantity: 1 })); + const source = { 华东: { 上海: records, 苏州: [{ ...records[0], city: '苏州' }] } }; + const result = inventoryGroups(source, 'default'); + assert.deepEqual(result[0].sections.map(([name]) => name), ['上海', '苏州']); + assert.deepEqual(result[0].sections[0][1].map(row => row.brand), ['现代', '帕力安牌', '帕力安', '宇通', '飞驰', '跃进', '苏龙', '明威', '其他']); + assert.deepEqual(records.map(row => row.brand), brands); + assert.equal(result[0].quantity, 10); +}); + +test('type view sorts complete model blocks by brand while preserving model grouping', () => { + const row = { ...filters, batch: '', quantity: 1 }; + const source = { '18T': { + 飞驰白车: [{ ...row, brand: '飞驰' }, { ...row, brand: '飞驰', city: '苏州' }], + 现代双飞翼: [{ ...row, brand: '帕力安牌' }], + 现代普货: [{ ...row, brand: '现代' }], + } }; + const result = inventoryGroups(source, 'default', 'model'); + assert.deepEqual(result[0].sections.map(([name]) => name), ['现代普货', '现代双飞翼', '飞驰白车']); + assert.equal(result[0].sections[2][1].length, 2); +}); + +test('brand and type groups combine nonadjacent variants while retaining all detail records', () => { + const rows: RegionalInventoryStats[] = [ + { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '', quantity: 9 }, + { ...filters, brand: '现代', type: '18T', model: '现代18T双飞翼(羚牛)', batch: '', quantity: 8 }, + { ...filters, brand: '飞驰', type: '49T', model: '49T飞驰', batch: '', quantity: 16 }, + { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(红车)', batch: '', quantity: 7 }, + ]; + const groups = inventoryBrandGroups(rows, 'type'); + assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']); + assert.deepEqual(groups[1].variants.map(variant => variant.map(row => row.quantity)), [[9, 7], [16]]); + assert.equal(groups[1].variants[0][0], rows[0]); + assert.equal(groups[1].variants[0][1], rows[3]); + assert.equal(groups.flatMap(group => group.rows).reduce((sum, row) => sum + row.quantity, 0), 40); + assert.deepEqual(rows.map(row => row.quantity), [9, 8, 16, 7]); + assert.deepEqual(groups[1].variants[0].map(row => inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value)), [['白车'], ['红车']]); +}); + +test('model grouping merges locations but keeps different brands, colors and batches separate', () => { + const base = { ...filters, brand: '飞驰', type: '18T', model: '飞驰18T(白车)', batch: '一期', quantity: 1 }; + const rows = [base, { ...base, city: '苏州' }, { ...base, batch: '二期' }, { ...base, model: '飞驰18T(红车)' }, { ...base, brand: '现代' }]; + const groups = inventoryBrandGroups(rows, 'model'); + assert.deepEqual(groups.map(group => group.brand), ['现代', '飞驰']); + assert.deepEqual(groups[1].variants.map(variant => variant.length), [2, 1, 1]); + assert.deepEqual(groups[1].variants[0].map(row => row.city), ['上海', '苏州']); + for (const row of groups.flatMap(group => group.rows)) { + assert.equal(inventorySelection(row, '明细').model, row.model); + assert.equal(inventorySelection(row, '明细').brand, row.brand); + assert.equal(inventorySelection(row, '明细').location, row.city); + } +}); diff --git a/src/modules/assets/components/overview/inventory-presentation.ts b/src/modules/assets/components/overview/inventory-presentation.ts new file mode 100644 index 0000000..6416dc8 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-presentation.ts @@ -0,0 +1,93 @@ +import type { InventoryFilters, VehicleModalSelection } from '../../model'; +import type { RegionalInventoryStats } from '../../types'; + +const BRAND_ORDER = ['现代', '帕力安', '宇通', '飞驰', '跃进']; +const normalizeBrand = (brand: string) => brand.trim().replace(/牌$/, ''); + +function compareInventoryBrand(left: string, right: string) { + const rank = (brand: string) => { + const normalized = normalizeBrand(brand); + if (!normalized || normalized === '其他') return BRAND_ORDER.length + 1; + const index = BRAND_ORDER.indexOf(normalized); + return index < 0 ? BRAND_ORDER.length : index; + }; + return rank(left) - rank(right); +} + +export function inventoryTotal(rows: RegionalInventoryStats[]) { + return rows.reduce((total, row) => total + row.quantity, 0); +} + +/** Decompose display text only; never change the model used for drilldown. */ +export function inventoryModelTags(row: Pick & Partial>) { + const tags: { kind: 'type' | 'model' | 'detail' | 'batch'; label: string; value: string }[] = []; + const type = row.type.trim(); + const model = row.model.trim(); + const details = Array.from(model.matchAll(/[((]([^()()]+)[))]/g), match => match[1].trim()).filter(Boolean); + const base = model.replace(/[((]([^()()]+)[))]/g, '').trim(); + // The type already has its own tag, so avoid repeating it in the model tag. + let compactModel = (type ? base.replace(type, '') : base).trim(); + // Alias model names can say “现代” even when the separate brand is “帕力安牌”. + // Strip only a leading brand, preserving body style, color and qualifiers. + const brands = [...new Set([row.brand || '', normalizeBrand(row.brand || ''), ...BRAND_ORDER, '帕力安牌'])] + .filter(brand => brand && brand !== '其他').sort((a, b) => b.length - a.length); + const prefix = brands.find(brand => compactModel.startsWith(brand)); + if (prefix) compactModel = compactModel.slice(prefix.length).replace(/^[\s-]+/, '').trim(); + if (type) tags.push({ kind: 'type', label: '车型', value: type }); + if (compactModel) tags.push({ kind: 'model', label: '型号', value: compactModel }); + for (const value of new Set(details)) tags.push({ kind: 'detail', label: '型号说明', value }); + const batch = row.batch?.trim(); + // The current API aliases batch to model; only render genuinely distinct values. + if (batch && batch !== model) tags.push({ kind: 'batch', label: '批次', value: batch }); + return tags; +} + +/** Preserve the displayed scope when opening a filtered total or group. */ +export function inventorySelection(filters: InventoryFilters, title: string, overrides: Partial = {}): VehicleModalSelection { + return { + batch: 'All', model: filters.model || 'All', + location: filters.city || filters.region || 'All', + brand: filters.brand || undefined, vehicleType: filters.type || undefined, + category: 'Inventory', source: 'asset', title, ...overrides, + }; +} + +export interface InventoryDisplayGroup { + name: string; + sections: [string, RegionalInventoryStats[]][]; + rows: RegionalInventoryStats[]; + quantity: number; +} + +/** Group presentation only: keep each original record for quantity and drilldown. */ +export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 'type' | 'model') { + const brands = new Map }>(); + for (const row of rows) { + const key = normalizeBrand(row.brand); + let brand = brands.get(key); + if (!brand) { + brand = { brand: row.brand, rows: [], variants: new Map() }; + brands.set(key, brand); + } + brand.rows.push(row); + const variantKey = groupBy === 'type' ? row.type : JSON.stringify([row.type, row.model, row.batch]); + const variant = brand.variants.get(variantKey) || []; + variant.push(row); + brand.variants.set(variantKey, variant); + } + return [...brands.values()].sort((a, b) => compareInventoryBrand(a.brand, b.brand)) + .map(brand => ({ ...brand, variants: [...brand.variants.values()] })); +} + +export function inventoryGroups(source: Record>, sort: 'default' | 'quantity', groupBy: 'region' | 'model' = 'region'): InventoryDisplayGroup[] { + const groups = Object.entries(source).map(([name, children]) => { + const sections: InventoryDisplayGroup['sections'] = Object.entries(children).map(([section, records]) => [ + section, [...records].sort((a, b) => compareInventoryBrand(a.brand, b.brand)), + ]); + // Keep city blocks intact; in the type view order whole model blocks by brand. + if (groupBy === 'model') sections.sort(([, a], [, b]) => compareInventoryBrand(a[0]?.brand || '', b[0]?.brand || '')); + const rows = sections.flatMap(([, records]) => records); + return { name, sections, rows, quantity: inventoryTotal(rows) }; + }); + return sort === 'quantity' ? groups.sort((a, b) => b.quantity - a.quantity) : groups; +} diff --git a/src/modules/assets/components/overview/inventory-statistics.css b/src/modules/assets/components/overview/inventory-statistics.css new file mode 100644 index 0000000..dfedcb1 --- /dev/null +++ b/src/modules/assets/components/overview/inventory-statistics.css @@ -0,0 +1,175 @@ +.inventory-statistics { + --inventory-bg: #fff; + --inventory-soft: #f8fafc; + --inventory-line: #e2e8f0; + --inventory-text: #17243b; + --inventory-muted: #59687c; + --inventory-accent: #2458cf; + --inventory-accent-soft: #eff5ff; + --inventory-warning: #a34d0a; + min-width: 0; + margin-bottom: 24px; + overflow: hidden; + border: 1px solid var(--inventory-line); + border-radius: 16px; + background: var(--inventory-bg); + color: var(--inventory-text); + box-shadow: 0 2px 6px rgb(15 23 42 / 3%); + font-size: 12px; + line-height: 1.5; +} +.inventory-statistics button { cursor: pointer; } +.inventory-statistics button:disabled { cursor: default; } +.inventory-statistics :is(button, select):focus-visible { outline: 3px solid var(--inventory-accent); outline-offset: 3px; } +.inventory-statistics button:active:not(:disabled) { opacity: .75; } +.inventory-heading, .inventory-toolbar, .inventory-filter-heading, .inventory-filter-actions, .inventory-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.inventory-heading { padding: 10px 16px; } +.inventory-title { display: flex; align-items: center; gap: 12px; min-width: 0; } +.inventory-title-icon { display: grid; place-items: center; flex-shrink: 0; width: 30px; height: 30px; border-radius: 8px; color: var(--inventory-accent); background: var(--inventory-accent-soft); } +.inventory-title h2 { font-size: 15px; font-weight: 700; } +.inventory-title p { margin-top: 0; font-size: 12px; color: var(--inventory-muted); } +.inventory-control, .inventory-text-button, .inventory-icon-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; border-radius: 6px; font-weight: 500; white-space: nowrap; } +.inventory-control { padding: 4px 10px; border: 1px solid var(--inventory-line); background: var(--inventory-bg); } +.inventory-control[aria-expanded="true"], .inventory-control:hover { color: var(--inventory-accent); border-color: var(--inventory-accent); } +.inventory-control-primary, .inventory-control-primary:hover { background: var(--inventory-accent); color: white; border-color: var(--inventory-accent); } +.inventory-text-button { padding: 4px 8px; color: var(--inventory-muted); } +.inventory-text-button:hover { color: var(--inventory-accent); background: var(--inventory-accent-soft); } +.inventory-icon-button { width: 32px; color: var(--inventory-muted); } +.inventory-filter-count { display: grid; place-items: center; height: 20px; min-width: 20px; border-radius: 5px; background: var(--inventory-accent-soft); color: var(--inventory-accent); font-size: 12px; } +.inventory-metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr; margin: 0 16px; border: 1px solid var(--inventory-line); border-radius: 8px; overflow: hidden; } +.inventory-metric { position: relative; padding: 8px 12px; text-align: left; min-width: 0; background: var(--inventory-bg); } +.inventory-metric + .inventory-metric { border-left: 1px solid var(--inventory-line); } +.inventory-metric > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-metric strong { display: block; font-size: 22px; font-weight: 650; letter-spacing: -.03em; line-height: 1.25; margin: 2px 0 0; font-variant-numeric: tabular-nums; } +.inventory-metric small { color: var(--inventory-muted); font-size: 12px; font-weight: 400; margin-left: 6px; } +.inventory-summary-note { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; padding: 4px 16px 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-metric-primary { background: var(--inventory-accent-soft); } +.inventory-metric-primary strong { color: var(--inventory-accent); } +.inventory-metric-warning:not(:disabled) strong { color: var(--inventory-warning); } +.inventory-metric:disabled > span > svg { visibility: hidden; } +.inventory-metric:not(:disabled):hover { background: var(--inventory-accent-soft); } +.inventory-metric:focus-visible { outline-offset: -4px !important; } +.inventory-filters { padding: 8px 16px 12px; background: var(--inventory-soft); border-block: 1px solid var(--inventory-line); } +.inventory-filter-heading { margin-bottom: 8px; } +.inventory-filter-fields { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; } +.inventory-filter-fields label { display: grid; gap: 4px; font-size: 12px; color: var(--inventory-muted); } +.inventory-statistics select { min-width: 0; max-width: 100%; min-height: 32px; padding: 4px 8px; border: 1px solid var(--inventory-line); border-radius: 8px; background: var(--inventory-bg); color: var(--inventory-text); font-size: 12px; cursor: pointer; } +.inventory-filter-actions { justify-content: flex-end; margin-top: 10px; gap: 8px; } +.inventory-filter-actions > :first-child { margin-right: auto; } +.inventory-active-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 16px 8px; font-size: 12px; color: var(--inventory-muted); } +.inventory-filters + .inventory-active-filters { padding-top: 16px; } +.inventory-active-filters button { display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px; min-height: 36px; border-radius: 6px; background: var(--inventory-accent-soft); color: var(--inventory-accent); } +.inventory-active-filters .inventory-clear { background: transparent; } +.inventory-toolbar { padding: 8px 16px; border-top: 1px solid var(--inventory-line); } +.inventory-view-switch { display: inline-flex; padding: 3px; background: var(--inventory-soft); border: 1px solid var(--inventory-line); border-radius: 10px; } +.inventory-view-switch button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 28px; padding: 4px 12px; border-radius: 6px; color: var(--inventory-muted); font-size: 12px; } +.inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); color: var(--inventory-accent); box-shadow: 0 1px 4px rgb(15 23 42 / 10%); font-weight: 600; } +.inventory-toolbar-actions { display: flex; align-items: center; gap: 12px; } +.inventory-sort select { min-width: 144px; padding-right: 28px; font-size: 12px; } +.inventory-table { width: 100%; border-collapse: collapse; table-layout: fixed; } +.inventory-table thead { background: var(--inventory-soft); color: var(--inventory-muted); font-size: 12px; } +.inventory-table th, .inventory-table td { padding: 4px 16px; border-bottom: 1px solid var(--inventory-line); text-align: left; overflow-wrap: anywhere; } +.inventory-table thead th { font-weight: 500; padding-block: 8px; } +.inventory-table[data-group-by="model"] thead th:first-child { width: 14%; } +.inventory-table[data-group-by="model"] thead th:nth-child(2) { width: 30%; } +.inventory-table[data-group-by="model"] thead th:nth-child(3) { width: 26%; } +.inventory-table[data-group-by="region"] thead th:first-child { width: 12%; } +.inventory-table[data-group-by="region"] thead th:nth-child(2) { width: 12%; } +.inventory-table[data-group-by="region"] thead th:nth-child(3) { width: 16%; } +.inventory-table[data-group-by="region"] thead th:nth-child(4) { width: 30%; } +.inventory-table .inventory-number-column { text-align: right; width: 14%; } +.inventory-table .inventory-share-column { width: 16%; } +.inventory-group-row { background: var(--inventory-soft); } +.inventory-group-row th { font-weight: 400; } +.inventory-group-heading { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; } +.inventory-expand { display: flex; align-items: center; gap: 8px; min-height: 30px; min-width: 0; text-align: left; } +.inventory-expand > svg { color: var(--inventory-muted); flex-shrink: 0; } +.inventory-expand[aria-expanded="true"] > svg { transform: rotate(90deg); } +.inventory-expand strong { font-size: 12px; font-weight: 650; } +.inventory-expand > span { font-size: 12px; font-weight: 400; color: var(--inventory-muted); } +.inventory-quantity { display: inline-flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 44px; min-height: 30px; border-radius: 6px; color: var(--inventory-accent); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; } +.inventory-quantity svg { opacity: .7; } +.inventory-quantity:not(:disabled):hover { text-decoration: underline; text-underline-offset: 4px; } +.inventory-quantity:disabled { color: var(--inventory-muted); } +.inventory-quantity:disabled svg { visibility: hidden; } +.inventory-share { display: flex; align-items: center; justify-content: flex-end; gap: 10px; color: var(--inventory-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.inventory-share > span:last-child { min-width: 45px; text-align: right; } +.inventory-share-track { flex: 1; height: 5px; border-radius: 4px; background: var(--inventory-line); overflow: hidden; } +.inventory-share-track > span { display: block; height: 100%; border-radius: inherit; background: var(--inventory-accent); } +.inventory-detail-row { font-size: 12px; } +.inventory-detail-row:hover { background: var(--inventory-accent-soft); } +.inventory-detail-row .inventory-merged-cell { font-weight: 500; padding: 10px 16px; vertical-align: top; background: var(--inventory-bg); border-right: 1px solid var(--inventory-line); } +.inventory-detail-row .inventory-city-cell { padding-left: 36px; } +.inventory-detail-group + .inventory-detail-group { border-top: 1px solid var(--inventory-line); } +.inventory-place-region { margin-right: 8px; color: var(--inventory-muted); } +.inventory-model-tags { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; max-width: 100%; vertical-align: middle; } +.inventory-tag { display: inline-block; max-width: 100%; padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 4px; background: var(--inventory-soft); color: var(--inventory-text); font-size: 12px; font-weight: 400; line-height: 18px; overflow-wrap: anywhere; } +.inventory-tag-type { background: var(--inventory-accent-soft); border-color: #dbe6fb; color: var(--inventory-accent); } +.inventory-tag-detail, .inventory-tag-batch { background: #f5f3ff; border-color: #e7e1f6; color: #69518b; } +.inventory-detail-row .inventory-share-track > span { opacity: .45; } +.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; } +.inventory-number-column [data-testid="inventory-status-counts"] { display: flex; justify-content: flex-end; } +.inventory-footer { flex-wrap: wrap; padding: 8px 16px; font-size: 12px; color: var(--inventory-muted); } +.inventory-empty { display: grid; justify-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--inventory-muted); border-block: 1px solid var(--inventory-line); } +.inventory-empty h3 { color: var(--inventory-text); font-weight: 600; font-size: 16px; } +.inventory-empty p { font-size: 13px; } +.inventory-mobile { display: none; } +@media (prefers-reduced-motion: no-preference) { + .inventory-statistics button, .inventory-expand > svg { transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } +} +@media (max-width: 1023px) { + .inventory-desktop { display: none; } + .inventory-mobile { display: grid; gap: 8px; padding: 0 16px 8px; } + .inventory-mobile-group { border: 1px solid var(--inventory-line); border-radius: 10px; overflow: hidden; } + .inventory-mobile-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 12px 0; } + .inventory-mobile-group-meta { display: flex; align-items: center; gap: 12px; padding: 0 12px 8px; font-size: 12px; color: var(--inventory-muted); } + .inventory-mobile-group-meta .inventory-share { flex: 1; } + .inventory-mobile-status { padding: 0 12px 6px; } + .inventory-mobile-details { border-top: 1px solid var(--inventory-line); padding: 0 12px 4px; } + .inventory-mobile-details h3 { padding: 8px 0 4px; font-size: 12px; font-weight: 600; color: var(--inventory-muted); overflow-wrap: anywhere; } + .inventory-mobile-brand { padding-block: 6px; border-top: 1px solid var(--inventory-line); } + .inventory-mobile-brand h4 { font-size: 12px; font-weight: 600; margin-bottom: 4px; } + .inventory-mobile-variant { padding-left: 10px; border-left: 2px solid var(--inventory-line); } + .inventory-mobile-variant + .inventory-mobile-variant { margin-top: 8px; } + .inventory-mobile-variant-heading { padding-top: 2px; } + .inventory-mobile-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 4px 8px; padding: 4px 0; border-bottom: 1px solid var(--inventory-line); } + .inventory-mobile-row:last-child { border-bottom: 0; } + .inventory-mobile-row > div:first-child { flex: 1; min-width: 0; } + .inventory-mobile-row strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } + .inventory-mobile-row p { font-size: 12px; color: var(--inventory-muted); margin-top: 2px; } + .inventory-mobile-row .inventory-mobile-status { width: 100%; padding: 0; } + .inventory-filter-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} +@media (max-width: 639px) { + .inventory-heading { padding: 10px 12px; align-items: flex-start; gap: 10px; } + .inventory-title { gap: 8px; } + .inventory-title-icon { display: none; } + .inventory-title h2 { font-size: 15px; } + .inventory-title p { max-width: 24ch; } + .inventory-heading .inventory-control { padding: 8px 10px; } + .inventory-heading .inventory-control > svg:last-child { display: none; } + .inventory-metrics { margin: 0 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .inventory-metric { padding: 8px; } + .inventory-metric strong { font-size: 20px; } + .inventory-metric > span:first-child { font-size: 12px; } + .inventory-toolbar { align-items: stretch; flex-direction: column; gap: 6px; padding: 8px 12px; } + .inventory-view-switch button { flex: 1; min-height: 36px; } + .inventory-toolbar-actions { justify-content: space-between; gap: 8px; } + .inventory-toolbar-actions .inventory-text-button { font-size: 12px; } + .inventory-mobile { padding: 0 12px 8px; } + .inventory-mobile-group-heading { padding-inline: 12px; } + .inventory-expand { gap: 6px; } + .inventory-expand strong { font-size: 12px; } + .inventory-expand > span { font-size: 12px; } + .inventory-filters { padding: 8px 16px 16px; } + .inventory-filter-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .inventory-filter-fields label:last-child { grid-column: 1 / -1; } + .inventory-filter-fields select { font-size: 16px; min-height: 44px; width: 100%; } + .inventory-filter-actions { gap: 6px; font-size: 12px; } + .inventory-active-filters { padding-inline: 16px; } + .inventory-active-filters button { min-height: 44px; } + .inventory-footer { padding: 8px 12px; gap: 4px; } + .inventory-summary-note { padding: 4px 12px 8px; } + .inventory-metric > span:first-child > svg { display: none; } + .inventory-mobile :is(.inventory-quantity, .inventory-expand) { min-height: 44px; } +} diff --git a/src/server/routes/vehicles/abnormal-move.test.ts b/src/server/routes/vehicles/abnormal-move.test.ts index 81f4867..c6b2636 100644 --- a/src/server/routes/vehicles/abnormal-move.test.ts +++ b/src/server/routes/vehicles/abnormal-move.test.ts @@ -27,3 +27,11 @@ test('completed movement retains its actual time and missing records stay distin assert.equal(completed.abnormalMove?.work, null); assert.equal(transformRow(row).abnormalMove, null); }); + + +test('applicant comes from the movement creator name and never falls back to contract manager', () => { + const base = { ...row, abnormal_move_id: 'move-1' }; + assert.equal(transformRow({ ...base, abnormal_move_applicant: ' 测试申请人 ' }).abnormalMove?.applicant, '测试申请人'); + assert.equal(transformRow({ ...base, abnormal_move_applicant: ' ' }).abnormalMove?.applicant, null); + assert.equal(transformRow(base).abnormalMove?.applicant, null); +}); diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index 9dbaa27..f793c3d 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -221,6 +221,7 @@ export function transformRow(row: VehicleRow): Vehicle { type: ({ 1: '维修', 2: '保养', 3: '年审', 4: '其他' } as Record)[Number(row.abnormal_move_type)] || '未提供', status: Number(row.abnormal_move_status) === 5 ? '已完成' : '待结束', work: row.abnormal_move_work?.trim() || null, + applicant: row.abnormal_move_applicant?.trim() || null, targetCompletionTime: row.abnormal_move_target_time || null, actualCompletionTime: row.abnormal_move_actual_time || null, // The source has no confirmed department, assignee or operating-driver fields. diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index b723faf..f6b7a1c 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -39,6 +39,7 @@ export const MAIN_SQL = `SELECT CAST(vi.id AS CHAR) AS id, CAST(am.id AS CHAR) AS abnormal_move_id, am.move_type AS abnormal_move_type, + NULLIF(TRIM(am_applicant.nick_name), '') AS abnormal_move_applicant, CAST(pdv.id AS CHAR) AS pending_delivery_id, pdts.contract_code AS pending_contract_no, pdts.customer_name AS pending_customer_name, @@ -147,6 +148,7 @@ LEFT JOIN vehicle_abnormal_move_record amr ON amr.id = ( LIMIT 1 ) LEFT JOIN vehicle_abnormal_move am ON am.id = amr.move_id +LEFT JOIN \`ry-cloud\`.sys_user am_applicant ON am_applicant.user_id = am.create_by ${LATEST_VEHICLE_LEASE_JOIN_SQL} WHERE vi.del_flag = '0' AND COALESCE(vs.operation_status, '') <> '5'`; diff --git a/src/server/types.ts b/src/server/types.ts index 731825f..277a623 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -8,6 +8,7 @@ export interface VehicleRow { abnormal_move_type?: number | null; abnormal_move_status?: number | null; abnormal_move_work?: string | null; + abnormal_move_applicant?: string | null; abnormal_move_target_time?: string | null; abnormal_move_actual_time?: string | null; id: string; diff --git a/src/shared/assets/abnormal-move.ts b/src/shared/assets/abnormal-move.ts index c2cd513..f2e6552 100644 --- a/src/shared/assets/abnormal-move.ts +++ b/src/shared/assets/abnormal-move.ts @@ -3,6 +3,8 @@ export interface AbnormalMoveDetail { id: string; type: string; work: string | null; + /** Applicant displayed from the movement creator account, not the vehicle owner. */ + applicant?: string | null; targetCompletionTime: string | null; actualCompletionTime: string | null; initiatingDepartment: string | null; From 7e9760f5db149e6daa5777d03f8f2125514bd7c8 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 16:01:10 +0800 Subject: [PATCH 2/3] feat(asset-ai): add contextual analysis and unmet-demand feedback --- docker-compose.yml | 2 + docs/asset-ai-experience-cases.md | 53 + docs/asset-ai-safe-analysis.md | 36 + docs/portainer-deepseek.md | 9 +- .../assets/assistant/AssetAssistant.tsx | 609 +++++++---- src/modules/assets/assistant/DemandGaps.tsx | 128 +++ src/modules/assets/assistant/ResultView.tsx | 946 +++++++++++------- .../assets/assistant/ThinkingState.tsx | 10 +- src/modules/assets/assistant/assistant.css | 281 ++++++ src/modules/assets/assistant/client.ts | 19 +- .../routes/asset-assistant/analysis.test.ts | 473 +++++++++ src/server/routes/asset-assistant/execute.ts | 215 ++++ .../routes/asset-assistant/experience.test.ts | 762 ++++++++++++++ .../routes/asset-assistant/gaps.test.ts | 26 + src/server/routes/asset-assistant/gaps.ts | 79 ++ src/server/routes/asset-assistant/index.ts | 642 +++++++----- .../routes/asset-assistant/model.test.ts | 15 +- src/server/routes/asset-assistant/model.ts | 246 ++++- .../routes/asset-assistant/planner.test.ts | 459 +++++++++ src/server/routes/asset-assistant/planner.ts | 640 ++++++++++++ src/server/routes/asset-assistant/privacy.ts | 98 ++ src/server/routes/asset-assistant/session.ts | 131 +++ src/shared/assets/assistant.ts | 116 ++- 23 files changed, 5160 insertions(+), 835 deletions(-) create mode 100644 docs/asset-ai-experience-cases.md create mode 100644 docs/asset-ai-safe-analysis.md create mode 100644 src/modules/assets/assistant/DemandGaps.tsx create mode 100644 src/server/routes/asset-assistant/analysis.test.ts create mode 100644 src/server/routes/asset-assistant/execute.ts create mode 100644 src/server/routes/asset-assistant/experience.test.ts create mode 100644 src/server/routes/asset-assistant/gaps.test.ts create mode 100644 src/server/routes/asset-assistant/gaps.ts create mode 100644 src/server/routes/asset-assistant/planner.test.ts create mode 100644 src/server/routes/asset-assistant/planner.ts create mode 100644 src/server/routes/asset-assistant/privacy.ts create mode 100644 src/server/routes/asset-assistant/session.ts diff --git a/docker-compose.yml b/docker-compose.yml index b6fba50..a5eda1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: environment: # Runtime only: set this variable in Portainer Stack environment variables. DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" + # Enabled when a Key is configured; set 0 to use the local planner only. + ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}" DB_HOST: "rm-bp179zbv481rnw3e2no.mysql.rds.aliyuncs.com" DB_PORT: "3306" DB_USER: "oneos_db_prod" diff --git a/docs/asset-ai-experience-cases.md b/docs/asset-ai-experience-cases.md new file mode 100644 index 0000000..5188191 --- /dev/null +++ b/docs/asset-ai-experience-cases.md @@ -0,0 +1,53 @@ +# 资产 AI 问答体验验收 + +本轮目标是恢复自然语言理解和连续分析。模型负责理解与查询规划,权限与业务数量继续由现有后端执行。验收需要分开检查“有没有理解”与“算得对不对”,不能仅以接口返回 200 为通过。 + +## 自动回归的数据与边界 + +`src/server/routes/asset-assistant/experience.test.ts` 使用合成车辆、模拟 DeepSeek 响应和内存需求记录,不访问真实数据库或模型。集成测试验证模型决策能穿过会话、查询执行和结果组装,不能证明真实模型对任意表达的理解率。 + +本轮已通过 17 项路由集成回归,包含五轮会话、澄清前后范围、历史结果分支、排名引用的汇总证据、异常到期筛选、历史流转日期及切回当前库存、模型故障、中文历史库存口径,以及需求记录的上下文可读性。执行命令:`node --import tsx --test src/server/routes/asset-assistant/experience.test.ts`。 + +## 中文真实问法语料 + +| 场景 | 连续输入 / 操作 | 应有行为 | +| --- | --- | --- | +| 口语表达 | 帮我瞅瞅华东现在有多少闲着的车,按城市排一下 | 理解库存意图;若“闲着”口径不明确,用简短说明或一次澄清,不要求用户使用固定关键词 | +| 指代和继承 | 查华东库存 → 只看嘉兴 → 这里面哪些快到期了? | 继承华东、嘉兴范围;“到期”可能指异动预计完成时间或合同到期,缺少明确指向时澄清 | +| 澄清恢复 | 查嘉兴库存 → 看下到期情况 → 我指异动预计完成时间 | 澄清不丢掉嘉兴范围;执行异动时间相关分析 | +| 排序与取前几名 | 哪些城市库存最多?只看前三名 → 换成最少的三个 | 正确应用降序、升序与 Top 3;展示截取口径,不能把前三名当作全量总数 | +| 排除条件 | 看华东库存,杭州先不要 → 还是把杭州加回来 | 支持排除和取消排除;展示当前条件 | +| 修改而非叠加 | 看嘉兴库存 → 不对,我想看杭州 | 城市替换为杭州,不把嘉兴和杭州错误叠加 | +| 恢复全局 | 看嘉兴异动车辆 → 重新看全部库存 | 清除旧地区、异常等限制,不残留不可见条件 | +| 任意结果分支 | 看华东库存 → 只看嘉兴 → 回到第一张图,按车型拆 | 从指定历史结果继续,使用华东范围;不能使用最近嘉兴结果 | +| 图表点击联动 | 按区域看库存 → 点击华东 → 按车型拆开 → 点击 4.5T → 列出异动车 | 保留区域和车型;统计与明细可核对;显示异动类型、预计完成时间 | +| 交叉对比 | 华东和华南的库存,按车型对比一下 | 地区与车型双维度;对比当前分布,不冒称历史涨跌 | +| 库存子集 | 这些库存里面有多少异动和待交车? | 总库存包含两个子集;不能将总库存与子集相加 | +| 超期定位 | 异动维修还没结束而且已经超期的有哪些? | 仅包括未完成且超过预计完成时间的车辆,显示明细依据 | +| 时间口语 | 最近一周交退换怎么样?和前一周比一下 → 换成表格 | 固定相同长度比较期间;切换展示不改变时间和比较条件 | +| 车辆指定 | 看一下浙A12345当前是什么情况 | 现有权限内查询对应车辆,不因自然文本被固定词表拦截 | +| 正常零结果 | 嘉兴有待交车吗?(样本中为 0) | 清楚回答 0,保留范围;不标记为未识别或生成失败,不自动进入需求池 | +| 能力说明 | 你能帮我分析些什么? → 库存和可运营库存有什么区别? | 直接说明口径或给可用示例;不把正常帮助对话登记为未满足需求 | +| 缺少数据 | 能算每台车的租赁毛利吗? | 说明当前缺哪些数据;不拿运营率替代毛利;记录实际需求且给出相关可用方向 | +| 原因解释 | 为什么这个城市库存高? | 先给已知分布证据,区分相关性和原因;没有业务原因数据时明确说明,不编造客户流失等原因 | +| 模型异常 | 模拟超时、非 JSON、截断或无效计划 | 简单可识别问题可以规则降级,并明确提示;复杂问题不能静默退成全部资产 | +| 权限一致 | 无权查看的车辆 / 公司 / 历史结果 | 沿用账号权限;模型选择不能扩大可见范围;导出重新核验权限 | +| 需求反馈 | 未支持问题 → 我的需求 → 标记未解决 → 管理员处理 | 保存用户实际需求,状态可追踪;重复点击同一结果的主动反馈只记录一次 | + +## 实机验收方法 + +1. 用真实模型跑口语、修正、澄清、历史分支四组会话,每组至少五轮;保留问题、生成计划和可核对结果。 +2. 用图表操作和文字提问交替完成同一个分析,核对筛选条件、总数和明细。 +3. 用桌面和手机分别完成输入、停止、重试、切换结果、导出;不得把旧结果误标成当前上下文。 +4. 检查需求池:正常帮助、澄清和零匹配不能污染需求池;不支持的真实需求应可读、可归类。 +5. 报告分别列出通过、失败及未验证项,明确真实模型验证与模拟集成测试的区别。 + + +## 本轮验证结果(2026-09-23) + +- 全量 176 项自动化测试通过,TypeScript 检查与生产构建通过;含 22 项规划器测试、17 项会话路由体验测试。 +- 真实 DeepSeek Flash 使用合成车辆验证口语库存、车型/型号区分、正负筛选、库存范围重置、Top N 指代、宽泛异常分析、异动详情和历史/预测能力说明。发现的理解偏差修复后重新调用验证通过;这是有限样本验收,不代表所有自然语言均已覆盖。 +- 浏览器合成数据验证桌面 1440×960 与手机 390×844:总库存 4 → 华东 3 → 嘉兴 2 → 取消城市恢复 3;异动 1 台并保留类型和预计完成时间;帮助后可继续提问,旧回答可折叠。手机无页面横向溢出,控制台无错误和警告。 +- 未进行生产部署、生产数据库需求表建表验证或长期真实用户效果评估。本轮保留工作区修改,未提交、推送或发布。 + +开发复盘:问题根因是封闭词表先行拦截、模型只负责展示、会话没有传递实际问答及结果上下文。改为模型完整规划并用真实中文多轮样本验证。本轮证据属于项目改造,不新增全局开发知识候选。 diff --git a/docs/asset-ai-safe-analysis.md b/docs/asset-ai-safe-analysis.md new file mode 100644 index 0000000..45cd504 --- /dev/null +++ b/docs/asset-ai-safe-analysis.md @@ -0,0 +1,36 @@ +# 资产 AI 会话分析与需求反哺 + +## 会话能力 + +- DeepSeek Flash 理解完整自然语言、当前分析状态、近期问答和可回到的历史结果,生成完整统计方案。支持口语、纠正、否定、切换主题和连续追问。 +- 内部程序执行筛选和计算;图表点击、文字追问、历史结果分支共享同一分析条件。澄清后补充条件仍承接最近有效的分析范围。 +- 区域、城市、车型、型号、部门、运营状态支持多层下钻和双维度交叉统计;支持排除条件、指定车辆、排名和排序。Top N 仅限制分组展示,总量、占比和明细仍基于完整筛选范围,界面明确显示口径。 +- 库存包含异动和待交车,子集不重复累加。支持超预计完成时间、未来 7 天到期、未填写预计完成时间的异动车辆定位,明细保留异动类型与预计完成时间。 +- 交退换车按日趋势支持上一等长周期比较,单次最多 93 天;替换交车包含在交车中,基期零不计算增长率。资产历史快照、成本、预测等数据能力尚未接入,说明原因并给下一步建议。 +- 展示包含指标、结论、图表、统计表、异常入口、车辆明细、导出。历史回答默认折叠,可展开或作为追问起点;当前范围可修改、清除或另起话题。 + +## 模型接入 + +按本轮产品要求,停用此前新增的封闭词表、未知文本拦截与实体代号投影。有 `DEEPSEEK_API_KEY` 时默认启用模型;显式 `ASSET_AI_EXTERNAL_ENABLED=0` 才使用本地规则。Compose 默认值已改为 1,既有容器如果显式设置了 0,需要重新配置并部署。 + +模型请求包含当前问题原文、最近会话文本、结构化分析条件、历史结果候选、当前结果的分组与指标和当前可见车辆的维度目录;问题明确提及某辆车时也包含匹配的车辆标识。历史回答和当前结果上下文包含之前计算得到的汇总数值。**这些内容会发送到 DeepSeek API。** 没有将完整数据库、轨迹或业务台账行打包发送;模型不执行 SQL。正常统计结论仍由内部确定性程序产生。 + +保留应用既有登录与数据权限、服务端密钥及查询执行机制。界面显示本次使用 DeepSeek 还是本地规则;模型超时、坏 JSON 或服务异常时给出降级提示,不把规则结果伪装成 AI。不会为本轮额外增设安全审批或词表限制。 + +## 需求反哺 + +未识别、暂不支持、执行生成失败会自动记录;每个结果也可标记“未解决”。正常帮助、针对性澄清和零匹配数据不作为需求失败。成功的本地降级只显示提示,不冒充理解能力完整的模型回答。 + +内部 MySQL 表 `bi_asset_ai_gaps` 保存未完成的问题文本(最多 600 字、常见 Key 格式隐藏)、原因、用户、次数和处理状态,不保存完整会话。按用户、问题与原因去重;用户对同一个结果重复点击“未解决”只记一次。普通用户查看本人记录;沿用反馈管理角色查看全部、设置待评估/已纳入计划/已解决/暂不处理。已解决需求再次出现会重新打开。 + +最近出现超过 90 天的记录会清理。数据库不可用时明确显示未保存。需求记录用于产品改进,不自动训练模型,不建立长期个人记忆,不向模型同步需求池。 + +## 部署与会话生命周期 + +环境变量和 Portainer 操作见 [部署说明](portainer-deepseek.md)。需求表复用现有 MySQL 连接,首次访问自动创建;预建 SQL 在 `src/server/routes/asset-assistant/gaps.ts`。表不可用不影响正常统计。 + +会话仅在应用进程内保存,闲置 1 小时失效;每用户最多 10 个会话,每会话最多 100 步。重启后可用原问题开启新对话,但不能恢复旧的分析范围。多副本需要粘性路由或共享会话存储。取消本轮新增的每日/每分钟问数额度,仅阻止同用户同时重复执行请求。 + +## 验证 + +自动化场景覆盖自然语言规划真实影响查询、连续纠正和排除、澄清上下文、历史分支、Top N 统计口径、异常期限、清除筛选、帮助与零结果、能力缺口与反馈、模型故障降级和现有权限。见 [体验验收语料](asset-ai-experience-cases.md)。浏览器与真实模型探针均使用合成车辆,不向业务库写测试记录。 diff --git a/docs/portainer-deepseek.md b/docs/portainer-deepseek.md index 6227547..44d915c 100644 --- a/docs/portainer-deepseek.md +++ b/docs/portainer-deepseek.md @@ -1,5 +1,7 @@ # Portainer 部署资产 AI 问数 +配置 Key 后默认使用 DeepSeek Flash 理解自然语言与会话上下文;`ASSET_AI_EXTERNAL_ENABLED=0` 可显式关闭模型。此前版本显式配置了 0 的 Stack,请改为 1。模型会收到问题原文、近期会话、分析条件和相关维度目录,详情见 [会话分析说明](asset-ai-safe-analysis.md)。 + DeepSeek Key 由 Node 服务端在运行时读取,变量名为 `DEEPSEEK_API_KEY`。模型固定为 `deepseek-flash`,接口为 `https://api.deepseek.com/chat/completions`,部署节点需要能够访问该 HTTPS 地址。 ## Stack 配置 @@ -11,15 +13,16 @@ services: ln-bi: environment: DEEPSEEK_API_KEY: "${DEEPSEEK_API_KEY:-}" + ASSET_AI_EXTERNAL_ENABLED: "${ASSET_AI_EXTERNAL_ENABLED:-1}" ``` -1. 等待当前分支的 Woodpecker 构建与镜像推送成功。本次版本 1.1.17 对应镜像 `harbor.lnh2e.com/lingniu-v1/ln-bi:feature-asset-statistics-1.1.17`。 -2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用上述镜像,且保留现有数据库、认证、网络和端口配置。 +1. 等待当前分支的 Woodpecker 构建与镜像推送成功,取得本次构建对应的镜像标签。 +2. Portainer → Stacks → 现有 `ln-bi` Stack。确认 Compose 的 `image` 使用本次构建的镜像,且保留现有数据库、认证、网络和端口配置。 3. 在 **Environment variables** 中新增名称 `DEEPSEEK_API_KEY`,值粘贴实际 API Key。Compose 必须包含上面的变量引用,才能将 Stack 变量传入容器。 4. 点击 **Update the stack** 并重新部署,确保拉取新镜像。Git 管理的 Stack 需要从仓库拉取新版 Compose;Web Editor 管理的 Stack 则直接更新编辑器内容。 5. 登录应用,在资产管理中打开「AI 问数」,查询“当前库存按区域统计”验证。 -后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 时 AI 问数返回配置提示,其他资产功能可继续使用。 +后续更换 Key 只需更新 Stack 环境变量并重新部署/创建容器,无需重新构建镜像。仅重启旧容器不会刷新其环境变量。未配置 Key 或显式关闭模型时使用本地规则,并在界面提示,其他资产功能可继续使用。 ## 检查是否注入 diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 072b80c..943cb15 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -2,17 +2,16 @@ import { useEffect, useRef, useState } from "react"; import { ArrowUp, ArrowUpRight, - AudioLines, BarChart3, Building2, CircleHelp, + ChevronDown, + RotateCcw, Database, History, MessageSquare, - Mic, Moon, Plus, - ShieldCheck, Sparkles, Square, Sun, @@ -27,13 +26,16 @@ import { } from "../../../components/ui/dialog"; import type { AssistantResult, - QueryPlan, + AssistantAction, + Dimension, } from "../../../shared/assets/assistant"; import { CATEGORY_LABELS, GROUP_LABELS, } from "../../../shared/assets/assistant"; -import { askAssetAssistant } from "./client"; +import { askAssetAssistant, AssistantRequestError } from "./client"; +import { fetchAuthenticated } from "../../../auth/api-client"; +import { DemandGaps } from "./DemandGaps"; import { ThinkingState } from "./ThinkingState"; import { ResultView } from "./ResultView"; import "./assistant.css"; @@ -45,46 +47,35 @@ type Turn = { steps: string[]; result?: AssistantResult; error?: string; -}; -type Recognition = { - lang: string; - continuous: boolean; - interimResults: boolean; - start(): void; - stop(): void; - abort(): void; - onresult: - | ((event: { - results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>; - }) => void) - | null; - onerror: ((event: { error: string }) => void) | null; - onend: (() => void) | null; + expired?: boolean; + stopped?: boolean; + baseResultId?: string; + action?: AssistantAction; }; const PROMPTS = [ { icon: BarChart3, - title: "库存分布", + title: "库存主要集中在哪里?", text: "各区域的库存车辆有多少?按数量排序。", - detail: "区域对比 · 一键下钻", + detail: "看分布,再逐层查看车辆", }, { icon: Truck, - title: "运营情况", - text: "按车型统计当前资产数量和运营率。", + title: "哪些车型运营率较低?", + text: "按车型对比当前资产数量和运营率。", detail: "车型构成 · 运营率", }, { icon: History, - title: "交退换趋势", + title: "本月车辆流转怎么样?", text: "本月的交车、还车和替换交车趋势怎么样?", detail: "按日趋势 · 业务明细", }, { icon: ListIcon, - title: "待交车清单", - text: "列出当前待交车车辆明细。", - detail: "车辆列表 · 导出结果", + title: "有哪些车辆需要跟进?", + text: "查看当前异动车辆,显示异动类型和预计完成时间。", + detail: "异动详情 · 预计完成时间", }, ]; function ListIcon({ size }: { size?: number }) { @@ -104,21 +95,74 @@ export default function AssetAssistant({ const [turns, setTurns] = useState([]); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); - const [listening, setListening] = useState(false); - const [voiceNote, setVoiceNote] = useState(""); + const [expandedTurns, setExpandedTurns] = useState>( + () => new Set(), + ); + const [independent, setIndependent] = useState(false); + const [capability, setCapability] = useState<{ + engine: "deepseek" | "local"; + model: string | null; + message: string; + }>(); const [showInfo, setShowInfo] = useState(false); + const [capabilityError, setCapabilityError] = useState(false); const abortRef = useRef(null); - const recognitionRef = useRef(null); + const [activeResult, setActiveResult] = useState(); + const sessionRef = useRef(undefined); + const sessions = useRef(new Map()); + const [showGaps, setShowGaps] = useState(false); const nextId = useRef(0); const composer = useRef(null); const scroller = useRef(null); - const lastPlan = turns - .filter((t) => t.result && t.result.plan.kind !== "clarify") - .at(-1)?.result?.plan; + const scopeResult = + activeResult?.plan.kind === "clarify" + ? activeResult.breadcrumbs + ?.map( + (crumb) => + turns.find((turn) => turn.result?.resultId === crumb.resultId) + ?.result, + ) + .filter((result) => result && result.plan.kind !== "clarify") + .at(-1) + : activeResult; + const lastPlan = scopeResult?.plan; + useEffect(() => { + const controller = new AbortController(); + void fetchAuthenticated("/api/asset-assistant/capabilities", { + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error("服务状态不可用"); + setCapability(await response.json()); + }) + .catch(() => { + if (!controller.signal.aborted) setCapabilityError(true); + }); + return () => controller.abort(); + }, []); + const revealTurn = (id: number) => { + setShowGaps(false); + setExpandedTurns((old) => new Set(old).add(id)); + requestAnimationFrame(() => + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), + ); + }; + const startTopic = () => { + setActiveResult(undefined); + sessionRef.current = undefined; + setIndependent(true); + composer.current?.focus(); + }; useEffect( () => () => { abortRef.current?.abort(); - recognitionRef.current?.abort(); + for (const [sessionId, sessionSubject] of sessions.current) + void fetchAuthenticated( + `/api/asset-assistant/session/${sessionId}?subject=${encodeURIComponent(sessionSubject || "")}`, + { method: "DELETE" }, + ).catch(() => {}); }, [], ); @@ -129,16 +173,19 @@ export default function AssetAssistant({ setTurns((previous) => previous.map((t) => !t.result && !t.error - ? { ...t, error: "已停止生成,可以修改问题后重试。" } + ? { ...t, error: "已停止。可以修改问题,或继续生成。", stopped: true } : t, ), ); }; - const ask = async (message: string, previous = lastPlan) => { + const ask = async ( + message: string, + base: AssistantResult | null = activeResult ?? null, + action?: AssistantAction, + ) => { if (!message.trim() || abortRef.current) return; - recognitionRef.current?.abort(); - setListening(false); - setVoiceNote(""); + setShowGaps(false); + setIndependent(false); const id = ++nextId.current; const controller = new AbortController(); abortRef.current = controller; @@ -150,35 +197,46 @@ export default function AssetAssistant({ id, question: message.trim(), started: Date.now(), - steps: ["连接资产分析服务"], + baseResultId: base?.resultId, + action, + steps: ["正在理解你的问题"], }, ]); requestAnimationFrame(() => - scroller.current?.scrollTo({ - top: scroller.current.scrollHeight, - behavior: "instant", - }), + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), ); try { await askAssetAssistant( - { message, subject, previous }, + { + ...(action ? { action } : { message }), + subject, + sessionId: base?.sessionId, + baseResultId: base?.resultId, + }, controller.signal, (event) => { if (controller.signal.aborted) return; - const container = scroller.current; - const follow = - container && - container.scrollHeight - - container.scrollTop - - container.clientHeight < - 150; - if (follow && event.type === "result") - requestAnimationFrame(() => - container.scrollTo({ - top: container.scrollHeight, - behavior: "instant", - }), - ); + if (event.type === "result") { + const container = scroller.current; + const following = + container && + container.scrollHeight - + container.scrollTop - + container.clientHeight < + 150; + if (following) + requestAnimationFrame(() => + document + .getElementById(`aq-turn-${id}`) + ?.scrollIntoView({ block: "start", behavior: "smooth" }), + ); + sessionRef.current = event.result.sessionId; + if (event.result.sessionId) + sessions.current.set(event.result.sessionId, subject); + setActiveResult(event.result); + } setTurns((old) => old.map((turn) => turn.id !== id @@ -187,7 +245,9 @@ export default function AssetAssistant({ ? { ...turn, steps: [...turn.steps, event.label] } : event.type === "error" ? { ...turn, error: event.message } - : { ...turn, result: event.result }, + : event.type === "result" + ? { ...turn, result: event.result } + : turn, ), ); }, @@ -199,6 +259,9 @@ export default function AssetAssistant({ t.id === id ? { ...t, + expired: + error instanceof AssistantRequestError && + error.status === 409, error: error instanceof Error ? error.message @@ -215,68 +278,27 @@ export default function AssetAssistant({ } } }; - const toggleVoice = () => { - if (listening) { - recognitionRef.current?.stop(); - return; - } - const win = window as typeof window & { - SpeechRecognition?: new () => Recognition; - webkitSpeechRecognition?: new () => Recognition; - }; - const Constructor = win.SpeechRecognition || win.webkitSpeechRecognition; - if (!Constructor) { - setVoiceNote( - "当前浏览器不支持语音识别,请使用 Chrome / Edge 或直接输入文字。", - ); - return; - } - const recognition = new Constructor(); - recognitionRef.current = recognition; - recognition.lang = "zh-CN"; - recognition.continuous = false; - recognition.interimResults = true; - const base = draft; - recognition.onresult = (event) => - setDraft( - (base ? base + " " : "") + - Array.from(event.results) - .map((r) => r[0].transcript) - .join(""), - ); - recognition.onerror = (event) => { - setListening(false); - setVoiceNote( - event.error === "not-allowed" - ? "麦克风权限未开启,你仍可以输入文字。" - : event.error === "no-speech" - ? "没有识别到语音,请再试一次。" - : "语音识别暂时不可用,请使用文字输入。", - ); - }; - recognition.onend = () => { - setListening(false); - composer.current?.focus(); - }; - try { - recognition.start(); - setListening(true); - setVoiceNote("正在聆听,点击麦克风结束。转写后可修改再发送。"); - } catch { - setVoiceNote("无法启动语音识别,请使用文字输入。"); - } - }; const reset = () => { abortRef.current?.abort(); abortRef.current = null; - recognitionRef.current?.abort(); setBusy(false); - setListening(false); - setVoiceNote(""); + setIndependent(false); + if (sessionRef.current) + void fetchAuthenticated( + `/api/asset-assistant/session/${sessionRef.current}?subject=${encodeURIComponent(subject || "")}`, + { method: "DELETE" }, + ).catch(() => {}); + sessionRef.current = undefined; + setActiveResult(undefined); + setShowGaps(false); setTurns([]); + setExpandedTurns(new Set()); setDraft(""); composer.current?.focus(); }; + useEffect(() => { + reset(); + }, [subject]); return ( +
本次对话{turns.length.toString().padStart(2, "0")}
{turns.length ? ( turns.map((turn) => ( - @@ -361,7 +380,7 @@ export default function AssetAssistant({

- + 沿用当前账号数据权限

@@ -375,6 +394,13 @@ export default function AssetAssistant({ 你的资产分析助手
+
)}
- {!turns.length ? ( + {showGaps ? ( + setShowGaps(false)} /> + ) : !turns.length ? (
@@ -456,93 +490,265 @@ export default function AssetAssistant({ ))}
- + 只读查询 · 真实数据 · 可追溯明细
) : (
- {turns.map((turn, index) => ( -
-
- 你 -

{turn.question}

-
-
- - - - 资产助手 - DeepSeek Flash -
- - {turn.error ? ( -
-

{turn.error}

+ {turns.map((turn, index) => { + const collapsed = + index < turns.length - 1 && + !!turn.result && + !expandedTurns.has(turn.id); + return ( +
+
+ 你 +

{turn.question}

+
+ {collapsed ? ( -
- ) : turn.result ? ( - - void ask( - text, - turn.result?.plan.kind === "clarify" - ? lastPlan - : turn.result?.plan, - ) - } - disabled={busy} - /> - ) : ( -
- - - -
- )} -
- ))} + ) : ( + <> +
+ + + + 资产助手 + + {(turn.result?.engine ?? capability?.engine) === + "deepseek" + ? "DeepSeek" + : (turn.result?.engine ?? + capability?.engine) === "local" + ? "本地分析" + : "连接中"} + + {index < turns.length - 1 && turn.result && ( + + )} +
+ + {turn.error ? ( +
+

+ {turn.expired + ? "这次分析已过期,重新查询即可获取最新数据。" + : turn.error} +

+ +
+ ) : turn.result ? ( + void ask(text, turn.result!)} + operate={(action, label) => + void ask(label, turn.result!, action) + } + restore={(resultId) => { + const target = turns.find( + (t) => t.result?.resultId === resultId, + )?.result; + if (target) + void ask("返回此前分析", target, { + type: "restore", + }); + }} + active={ + activeResult?.resultId === turn.result.resultId + } + activate={() => { + setActiveResult(turn.result); + sessionRef.current = turn.result?.sessionId; + setIndependent(false); + composer.current?.focus(); + }} + subject={subject} + disabled={busy} + /> + ) : ( +
+ + + +
+ )} + + )} + + ); + })}
)}
{lastPlan && (
- 追问上下文 + 正在分析 {lastPlan.kind === "flow" ? "交退换车" : CATEGORY_LABELS[lastPlan.category]} {GROUP_LABELS[lastPlan.groupBy]} - {Object.values(lastPlan.filters).map((v) => ( - {v} + {lastPlan.start && ( + + {lastPlan.start} — {lastPlan.end} + + )} + {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + ))} + {Object.entries(lastPlan.excludeFilters || {}).map( + ([dimension, values]) => ( + + ), + )} + {!!lastPlan.vehicleIds?.length && ( + 已选 {lastPlan.vehicleIds.length} 台车辆 + )} + {lastPlan.exception && ( + + {lastPlan.exception === "overdue" + ? "已超过预计完成时间" + : lastPlan.exception === "dueSoon" + ? "即将到期" + : "未填写预计完成时间"} + + )} + {Object.keys(lastPlan.filters).length > 0 || + Object.keys(lastPlan.excludeFilters || {}).length > 0 || + lastPlan.vehicleIds?.length || + lastPlan.exception ? ( + + ) : null} +
)} + {independent && ( +

+ 已开始独立问题,不继承之前的筛选条件 +

+ )}
{ e.preventDefault(); void ask(draft); @@ -576,9 +782,15 @@ export default function AssetAssistant({ />
- + - DeepSeek Flash + {capability?.engine === "deepseek" + ? "DeepSeek" + : capability?.engine === "local" + ? "本地分析 · 未连接模型" + : capabilityError + ? "服务状态待确认" + : "检查服务中"} @@ -591,16 +803,6 @@ export default function AssetAssistant({ {draft.length}/2000 )} - {busy ? (
- {voiceNote && ( -

- {voiceNote} -

- )}
AI 可能产生偏差,统计以业务明细为准。 Enter 发送 · Shift + Enter 换行 diff --git a/src/modules/assets/assistant/DemandGaps.tsx b/src/modules/assets/assistant/DemandGaps.tsx new file mode 100644 index 0000000..420d867 --- /dev/null +++ b/src/modules/assets/assistant/DemandGaps.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { fetchAuthenticated } from "../../../auth/api-client"; +import { + GAP_REASONS, + GAP_STATUSES, + type DemandGap, + type GapStatus, +} from "../../../shared/assets/assistant"; + +export function DemandGaps({ close }: { close: () => void }) { + const [items, setItems] = useState([]), + [all, setAll] = useState(false), + [canManage, setCanManage] = useState(false); + const [error, setError] = useState(""), + [loading, setLoading] = useState(true), + [revision, setRevision] = useState(0); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + void fetchAuthenticated( + `/api/asset-assistant/gaps?scope=${all ? "all" : "mine"}`, + { signal: controller.signal }, + ) + .then(async (r) => { + const data = await r.json(); + if (!r.ok) throw new Error(data.error); + return data; + }) + .then((data) => { + setItems(data.items); + setCanManage(data.canManage); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(e.message || "读取失败。"); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [all, revision]); + const update = async (id: number, status: GapStatus) => { + setError(""); + try { + const r = await fetchAuthenticated(`/api/asset-assistant/gaps/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }); + if (!r.ok) throw new Error("状态保存失败。"); + setRevision((x) => x + 1); + } catch { + setError("状态保存失败,请重试。"); + } + }; + return ( +
+
+
+

待完善需求

+

保留90天 · 记录未完成的问题与反馈,用于完善分析能力

+
+ +
+
+ {canManage && ( + + )} + +
+ {error && ( +

+ {error} +

+ )} + {loading ? ( +

正在读取需求记录…

+ ) : ( + <> +

+ {items.length}项需求 · 累计 + {items.reduce((n, v) => n + v.occurrences, 0)}次(最近200项) +

+ {items.length === 0 && !error && ( +

暂无未满足需求

+ )} + {items.map((item) => ( +
+ {item.demand} +

+ {GAP_REASONS[item.reason]} · {item.occurrences}次 · 最近{" "} + {new Date(item.lastSeen).toLocaleString("zh-CN")} +

+ {canManage ? ( + + ) : ( + {GAP_STATUSES[item.status]} + )} +
+ ))} + + )} +
+ ); +} diff --git a/src/modules/assets/assistant/ResultView.tsx b/src/modules/assets/assistant/ResultView.tsx index 8056d3b..a816e08 100644 --- a/src/modules/assets/assistant/ResultView.tsx +++ b/src/modules/assets/assistant/ResultView.tsx @@ -1,53 +1,35 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { ArrowDownToLine, - ArrowLeft, ArrowUpRight, - BarChart3, Check, ChevronLeft, ChevronRight, Copy, - List, - Table2, } from "lucide-react"; -import type { - AssistantResult, - AssistantRow, +import { + CATEGORY_LABELS, + DIMENSIONS, + GROUP_LABELS, + type AssistantAction, + type AssistantResult, + type AssistantRow, + type Dimension, + type QueryPlan, } from "../../../shared/assets/assistant"; -import { GROUP_LABELS } from "../../../shared/assets/assistant"; +import { fetchAuthenticated } from "../../../auth/api-client"; import { Chart } from "./Chart"; -function downloadCsv(rows: string[][], title: string) { - const content = rows - .map((row) => - row - .map( - (value) => - `"${String(value) - .replace(/^[=+@\-\t\r]/, "'$&") - .replaceAll('"', '""')}"`, - ) - .join(","), - ) - .join("\r\n"); - const url = URL.createObjectURL( - new Blob(["\uFEFF" + content], { type: "text/csv;charset=utf-8;" }), - ); - const a = document.createElement("a"); - a.href = url; - a.download = `${title}.csv`; - a.click(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); -} function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { - const [page, setPage] = useState(0); - const [search, setSearch] = useState(""); - const filtered = rows.filter((row) => - Object.values(row).some((value) => String(value).includes(search)), + const [page, setPage] = useState(0), + [search, setSearch] = useState(""); + const filtered = rows.filter((r) => + [r.plate, r.city, r.type, r.model, r.department, r.abnormalType].some((v) => + v?.includes(search), + ), ); - const max = Math.max(1, Math.ceil(filtered.length / 8)); - const current = Math.min(page, max - 1); + const pages = Math.max(1, Math.ceil(filtered.length / 8)), + current = Math.min(page, pages - 1); return ( <>
@@ -55,50 +37,58 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { 筛选明细 { setSearch(e.target.value); setPage(0); }} /> - {filtered.length.toLocaleString()} 条记录 + {filtered.length}条
- - - - - {flow && } + {[ + "车牌", + "车型 / 型号", + "城市", + flow ? "业务类型" : "运营状态", + ...(flow ? ["日期"] : ["库存子集", "异动类型", "预计完成时间"]), + ].map((h) => ( + + ))} - {filtered.slice(current * 8, current * 8 + 8).map((row) => ( - - + {filtered.slice(current * 8, current * 8 + 8).map((r) => ( + + - - - {flow && } + + + {flow ? ( + + ) : ( + <> + + + + + )} ))}
车牌车型 / 型号城市{flow ? "业务类型" : "运营状态"}日期{h}
{row.plate || "未登记车牌"}
{r.plate || "未登记车牌"} - {row.type} - {row.model} + {r.type} + {r.model} {row.city} - - {flow ? row.event : row.status} - - {row.date}{r.city}{flow ? r.event : r.status}{r.date}{r.abnormal ? "异动" : r.pending ? "待交车" : "—"}{r.abnormalType || "—"}{r.targetCompletionTime || "—"}
- {!filtered.length &&
没有符合条件的明细
} + {!filtered.length &&

没有符合条件的明细

}
- 第 {current + 1} / {max} 页 + 第{current + 1}/{pages}页
+ {search && ( +

+ 此搜索仅筛选当前明细表;继续追问仍使用上方分析条件。 +

+ )} ); } - export function ResultView({ result, ask, + operate, + restore, + active, + activate, + subject, disabled, }: { result: AssistantResult; ask: (text: string) => void; + operate: (action: AssistantAction, label: string) => void; + restore: (resultId: string) => void; + active: boolean; + activate: () => void; + subject: string | null; disabled: boolean; }) { - const [view, setView] = useState(result.plan.view); - const [drill, setDrill] = useState(null); - const [copied, setCopied] = useState(false); - const [copyError, setCopyError] = useState(false); - const [showAll, setShowAll] = useState(false); - const flow = result.plan.kind === "flow"; - const inventory = !["all", "operating"].includes(result.plan.category); - const rows = - drill === null - ? result.rows - : result.rows.filter((row) => - flow - ? row.date === drill - : result.plan.groupBy === "none" || - row[result.plan.groupBy] === drill, - ); + const [showAll, setShowAll] = useState(false), + [notice, setNotice] = useState(""), + [flagged, setFlagged] = useState(false), + [saving, setSaving] = useState(false); + const { plan } = result, + flow = plan.kind === "flow"; + const run = (action: AssistantAction, label: string) => + operate(action, label); + const flag = async () => { + setSaving(true); + setNotice(""); + try { + const r = await fetchAuthenticated("/api/asset-assistant/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: result.sessionId, + resultId: result.resultId, + subject, + }), + }); + if (!r.ok) throw new Error(); + setFlagged(true); + setNotice("已记录,我们会据此完善问答能力。"); + } catch { + setNotice("需求未保存,请稍后重试。"); + } finally { + setSaving(false); + } + }; + const download = async () => { + setSaving(true); + setNotice(""); + try { + const r = await fetchAuthenticated("/api/asset-assistant/export", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: result.sessionId, + resultId: result.resultId, + subject, + }), + }); + if (!r.ok) throw new Error(); + const url = URL.createObjectURL(await r.blob()); + const a = document.createElement("a"); + a.href = url; + a.download = "资产分析明细.csv"; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + setNotice("已按当前权限重新查询并导出明细,数量可能随业务更新变化。"); + } catch { + setNotice("导出失败,请重新查询后重试。"); + } finally { + setSaving(false); + } + }; + const suggestions = result.suggestions?.length + ? result.suggestions + : plan.kind === "clarify" + ? [ + "查看当前库存分布", + "查看本月交退换车趋势", + "列出异动车辆及预计完成时间", + ] + : !result.rows.length + ? ["清除筛选,查看全部库存", "查看全部资产分布"] + : flow + ? ["对比上一周期", "显示车辆明细"] + : plan.category === "abnormal" + ? ["哪些异动车辆已经超期?", "按异动所在城市统计", "显示车辆明细"] + : plan.view === "list" + ? ["按城市统计这些车辆", "这些车里面有多少异动和待交车"] + : [ + plan.groupBy === "type" ? "再按城市拆分" : "再按车型拆分", + "这些车里面有多少异动和待交车", + "显示车辆明细", + ]; const largest = Math.max(1, ...result.groups.map((g) => g.count)); - const total = result.groups.reduce((n, g) => n + g.count, 0); - const option = useMemo( - () => ({ - color: ["#668874", "#b5bdc4", "#a58b65"], - tooltip: { trigger: "axis" }, - legend: { bottom: 0, textStyle: { color: "#7b7b80" } }, - grid: { left: 42, right: 16, top: 20, bottom: 60 }, - xAxis: { - type: "category", - data: result.daily?.map((d) => d.date.slice(5)), - axisLine: { lineStyle: { color: "#b5bdc4" } }, - axisLabel: { color: "#7b7b80" }, - }, - yAxis: { - type: "value", - minInterval: 1, - splitLine: { lineStyle: { color: "#b5bdc430" } }, - axisLabel: { color: "#7b7b80" }, - }, - series: ["delivered", "returned", "replaced"].map((key, i) => ({ - name: ["交车(含替换)", "还车", "其中替换交车"][i], - type: "line", - smooth: false, - symbolSize: 5, - data: result.daily?.map((d) => d[key as "delivered"]), - })), - animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, - }), - [result.daily], - ); - const exportResult = () => { - if (drill !== null || view === "list") - downloadCsv( - [ - [ - "车牌", - "车型", - "型号", - "区域", - "城市", - "部门", - "运营状态", - "日期", - "业务类型", - ], - ...rows.map((r) => [ - r.plate, - r.type, - r.model, - r.region, - r.city, - r.department, - r.status, - r.date || "", - r.event || "", - ]), - ], - result.title + "-明细", - ); - else if (flow) - downloadCsv( - [ - ["日期", "交车(含替换)", "还车", "其中替换交车", "业务总车次"], - ...(result.daily || []).map((d) => [ - d.date, - String(d.delivered), - String(d.returned), - String(d.replaced), - String(d.total), - ]), - ], - result.title, - ); - else if (inventory) - downloadCsv( - [ - [GROUP_LABELS[result.plan.groupBy], "车辆数", "占比"], - ...result.groups.map((g) => [ - g.name, - String(g.count), - `${total ? ((g.count / total) * 100).toFixed(1) : 0}%`, - ]), - ], - result.title, - ); - else - downloadCsv( - [ - [GROUP_LABELS[result.plan.groupBy], "车辆数", "运营车辆数", "运营率"], - ...result.groups.map((g) => [ - g.name, - String(g.count), - String(g.operating), - `${g.rate}%`, - ]), - ], - result.title, - ); + const option = { + color: ["#668874", "#b5bdc4", "#a58b65"], + tooltip: { trigger: "axis", renderMode: "richText" }, + legend: { bottom: 0 }, + grid: { left: 45, right: 15, top: 20, bottom: 65 }, + xAxis: { + type: "category", + data: result.daily?.map((d) => d.date.slice(5)), + }, + yAxis: { type: "value", minInterval: 1 }, + series: (["delivered", "returned", "replaced"] as const).map((key, i) => ({ + name: ["交车(含替换)", "还车", "其中替换"][i], + type: "line", + data: result.daily?.map((d) => d[key]), + })), + animation: false, }; return ( -
+

{result.summary}

+ {result.contextNote && ( +

{result.contextNote}

+ )} + {result.interpretation && ( +
+ 查看问题理解 +

{result.interpretation}

+
+ )} {result.warning && ( -

+

{result.warning}

)} - {result.plan.kind !== "clarify" && ( + {result.gap && ( +

+ {result.gap.recorded + ? "这个需求已记下,可在「待完善需求」中查看。" + : "可以点击下方「未解决我的问题」记录需求。"} +

+ )} + {plan.kind !== "clarify" && ( <> +
真实业务数据 {result.source} + {result.engine === "local" && 本地查询结果}
@@ -268,196 +286,418 @@ export function ResultView({

{result.title}

- {result.metrics.map((metric) => ( -
- {metric.label} + {result.metrics.map((m) => ( +
+ {m.label} - {metric.value.toLocaleString()} - {metric.unit} + {m.value.toLocaleString()} + {m.unit}
))}
-
- {drill !== null ? ( - - ) : ( -
- {( - [ - { id: "chart", name: "图表", icon: BarChart3 }, - { id: "table", name: "统计表", icon: Table2 }, - { id: "list", name: "明细", icon: List }, - ] as const - ).map((tab) => ( - - ))} -
+
+ 调整维度与分析方式 +
+ {!flow && ( + <> + + + + + )} + {flow && ( + + )} +
+
+
+ {CATEGORY_LABELS[plan.category]} + {plan.exception && ( + + {plan.exception === "overdue" + ? "已超过预计完成时间" + : plan.exception === "dueSoon" + ? "即将到期" + : "未填写预计完成时间"} + )} - - {flow - ? `${result.plan.start} — ${result.plan.end}` - : "单位:台"} + {Object.entries(plan.filters).map(([d, v]) => ( + + {GROUP_LABELS[d as Dimension]}:{v.join("、")} + + ))} + {Object.entries(plan.excludeFilters || {}).map( + ([dimension, values]) => ( + + 排除{GROUP_LABELS[dimension as Dimension]}: + {values.join("、")} + + ), + )} + {!!plan.vehicleIds?.length && ( + 已选 {plan.vehicleIds.length} 台车辆 + )} + {plan.topN && ( + 展示前 {plan.topN} 组 · 总计与明细包含全部匹配车辆 + )} + {flow && ( + + {plan.start} — {plan.end} + + )} +
+ {!!result.insights?.length && ( +
    + {result.insights.map((text) => ( +
  • {text}
  • + ))} +
+ )} + {!flow && ( +
+ {result.exceptions?.map((e) => ( + + ))} +
+ )} + {result.groupComparison && ( +
+

+ 当前分组对比 · 数量最多的分组为基准: + {result.groupComparison.baseline} +

+ + + + {[ + "分组", + "车辆数", + "较基准差值", + "占比", + "运营率差(百分点)", + ].map((h) => ( + + ))} + + + + {result.groupComparison.rows.map((r) => ( + + + + + + + + ))} + +
{h}
{r.name}{r.count}{r.delta}{r.share}%{r.rateDelta}
+
+ )} + {result.comparison && ( +
+

+ 上一等长周期:{result.comparisonPeriod?.start} —{" "} + {result.comparisonPeriod?.end} +

+ + + + {["指标", "本期", "上期", "差值", "变化率"].map((h) => ( + + ))} + + + + {result.comparison.map((m) => ( + + + + + + + + ))} + +
{h}
{m.label}{m.current}{m.previous} + {m.delta > 0 ? "+" : ""} + {m.delta} + + {m.rate === null ? "基期为0,不计算" : `${m.rate}%`} +
+
+ )} +
+
+ {(["chart", "table", "list"] as const).map((v, i) => ( + + ))} +
+ + {result.rows.length} + {flow ? "笔业务" : "台车辆"}
- {drill !== null || view === "list" ? ( - - ) : view === "chart" && flow ? ( -
- -
- {result.groups - .filter((g) => g.count) - .map((g) => ( - - ))} + {plan.view === "list" ? ( + + ) : flow ? ( + <> + {plan.view === "chart" && ( +
+ +
+ )} +
+ + + + {[ + "日期", + "交车(含替换)", + "还车", + "其中替换", + "查看", + ].map((h) => ( + + ))} + + + + {result.daily?.map((d) => ( + + + + + + + + ))} + +
{h}
{d.date}{d.delivered}{d.returned}{d.replaced} + +
-
- ) : view === "chart" ? ( + + ) : plan.view === "chart" ? (
{(showAll ? result.groups : result.groups.slice(0, 8)).map( - (group, i) => ( + (g) => ( ), )} - {!result.groups.length && ( -
- 当前筛选范围没有车辆,可调整条件后重试。 -
- )} {result.groups.length > 8 && ( )} -
- 按数量降序排列 · 点击任意分组查看明细 -
+

+ 点击分组继续下钻 · 筛选条件自动用于下一次追问 +

) : (
- {(flow - ? ["日期", "交车(含替换)", "还车", "其中替换", "明细"] - : inventory - ? [ - GROUP_LABELS[result.plan.groupBy], - "车辆数", - "占比", - "明细", - ] - : [ - GROUP_LABELS[result.plan.groupBy], - "车辆数", - "运营数", - "运营率", - "明细", - ] - ).map((h) => ( + {[ + "分组", + "车辆数", + "占比", + "运营率", + "异动", + "待交车", + "下钻", + ].map((h) => ( ))} - {flow - ? result.daily?.map((d) => ( - - - - - - - - )) - : result.groups.map((g) => ( - - - - {inventory ? ( - - ) : ( - <> - - - - )} - - - ))} + {result.groups.map((g) => ( + + + + + + + + + + ))}
{h}
{d.date}{d.delivered}{d.returned}{d.replaced} - -
{g.name}{g.count} - {total - ? ((g.count / total) * 100).toFixed(1) - : 0} - % - {g.operating}{g.rate}% - -
{g.name}{g.count}{g.share}%{g.rate}%{g.abnormal}{g.pending} + +
- {!result.groups.length && ( -
暂无符合条件的数据
- )}
)} + {!result.rows.length && ( +

+ 当前条件没有匹配数据,可返回上层调整。 +

+ )}
查询于{" "} @@ -465,44 +705,50 @@ export function ResultView({ hour12: false, })} - 口径与权限已应用 + 每次操作重新查询,数据可能更新
-
- - {copyError && ( - 复制失败,请手动选择文字复制。 - )} - AI 解读供参考,请以统计表和明细为准。 -
-
- 继续探索 - {(flow - ? ["改成表格", "显示车辆明细"] - : ["再按车型拆分", "只看可运营库存", "改成表格"] - ).map((q) => ( - - ))} -
)} +
+ {plan.kind === "clarify" ? "你可以这样继续" : "继续分析"} + {suggestions.slice(0, 4).map((question) => ( + + ))} +
+
+ {plan.kind !== "clarify" && ( + + )} + + +
+ {notice && ( +

+ {notice} +

+ )}
); } diff --git a/src/modules/assets/assistant/ThinkingState.tsx b/src/modules/assets/assistant/ThinkingState.tsx index 9a51fff..07d288c 100644 --- a/src/modules/assets/assistant/ThinkingState.tsx +++ b/src/modules/assets/assistant/ThinkingState.tsx @@ -23,7 +23,7 @@ export function ThinkingState({ const timer = window.setInterval(update, 100); return () => window.clearInterval(timer); }, [working, started]); - const expanded = manualExpanded ?? working; + const expanded = manualExpanded ?? false; return (
}> {ActiveComponent && } - + {activeModule !== 'assets' && } {/* 移动端底部导航 (md 以下) */} diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index fc3a8c7..83b8c70 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -1,6 +1,7 @@ import React, { lazy, Suspense, useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Truck, + Sparkles, ChevronDown, ChevronRight, Loader2, @@ -483,8 +484,6 @@ export default function AssetsModule() { return (
setAssistantOpen(true)} activeTab={activeTab} setActiveTab={setActiveTab} theme={theme} @@ -1708,6 +1707,18 @@ export default function AssetsModule() {
+ {assistantOpen && 正在打开 AI 问数…
}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />}
); diff --git a/src/modules/assets/components/AssetsHeader.tsx b/src/modules/assets/components/AssetsHeader.tsx index 50eef42..9b690ca 100644 --- a/src/modules/assets/components/AssetsHeader.tsx +++ b/src/modules/assets/components/AssetsHeader.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { ChevronDown, Filter, Loader2, Search, Sparkles } from 'lucide-react'; +import { ChevronDown, Filter, Loader2, Search } from 'lucide-react'; import { motion } from 'motion/react'; import type { SubjectOption } from '../api'; @@ -7,8 +7,6 @@ export type AssetsTab = 'overview' | 'department' | 'region' | 'customer'; export type AssetsTheme = 'soft' | 'minimal' | 'vibrant'; interface AssetsHeaderProps { - onOpenAssistant: () => void; - assistantTrigger: React.RefObject; activeTab: AssetsTab; setActiveTab: React.Dispatch>; theme: AssetsTheme; @@ -33,8 +31,6 @@ const TABS = [ ]; export function AssetsHeader({ - onOpenAssistant, - assistantTrigger, activeTab, setActiveTab, theme, @@ -81,11 +77,6 @@ export function AssetsHeader({
-
- -
{/* 归属公司作用域筛选 (Scope Chip) */}