From b5a48b641766c49dccd93ed84c7b20960426498d Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 17:52:33 +0800 Subject: [PATCH 1/2] feat(assets): refine compact inventory drilldowns and exact subtotal scopes --- src/modules/assets/api.ts | 7 +- .../assets/components/VehicleDetailModal.tsx | 4 +- .../overview/InventoryDesktopTable.tsx | 38 ++-- .../overview/InventoryMobileList.tsx | 88 ++++++--- .../overview/InventoryStatisticsSection.tsx | 8 +- .../overview/InventoryStatusCounts.tsx | 4 +- .../overview/inventory-presentation.test.ts | 147 +++++++++++++- .../overview/inventory-presentation.ts | 93 ++++++++- .../overview/inventory-statistics.css | 186 +++++++++++++----- src/modules/assets/model.ts | 18 +- src/server/routes/vehicles.ts | 16 +- src/server/routes/vehicles/drilldown.test.ts | 28 +++ src/server/routes/vehicles/model.test.ts | 3 + src/server/routes/vehicles/model.ts | 1 + src/server/routes/vehicles/repository.test.ts | 4 +- 15 files changed, 534 insertions(+), 111 deletions(-) diff --git a/src/modules/assets/api.ts b/src/modules/assets/api.ts index 30ac0cc..861a37f 100644 --- a/src/modules/assets/api.ts +++ b/src/modules/assets/api.ts @@ -37,6 +37,7 @@ export async function fetchByType(subject?: string | null): Promise(`${BASE}/list?${query.toString()}`); + return fetchJson(`${BASE}/list?${query.toString()}`, params.inventoryKeys ? { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inventoryKeys: params.inventoryKeys }), + } : undefined); } export interface WeeklyDetailItem { diff --git a/src/modules/assets/components/VehicleDetailModal.tsx b/src/modules/assets/components/VehicleDetailModal.tsx index 552b157..970c509 100644 --- a/src/modules/assets/components/VehicleDetailModal.tsx +++ b/src/modules/assets/components/VehicleDetailModal.tsx @@ -67,14 +67,14 @@ export function VehicleDetailModal({ )}

- {showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} | + {showPlateNumbers.scopeLabel || <>{showPlateNumbers.model === 'All' ? '全量型号' : showPlateNumbers.model} | {showPlateNumbers.category === 'Pending' ? '待交车' : showPlateNumbers.category === 'Delivered' ? '本周已交车' : showPlateNumbers.category === 'Returned' ? '已还车' : showPlateNumbers.category === 'Replaced' ? '已替换' : showPlateNumbers.category === 'Abnormal' ? '异动' : showPlateNumbers.category === 'Inventory' ? `${showPlateNumbers.location}库存` : - showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'} + showPlateNumbers.category === 'Operating' ? '正在运营' : '全部状态'}}

+ + ; +} + 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) => + return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) => + (tab === 'model' ? inventoryRegionRows(variants) : variants.map(row => ({ row, regionSpan: 0 }))).map(({ row, regionSpan }, index) => - {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && {city || '未分配'}} - {variantIndex === 0 && index === 0 && {brand.brand || '—'}} + {tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && {city || '未分配'}} + {variantIndex === 0 && index === 0 && {brand.brand || '—'}} {index === 0 && - {tab === 'region' ? {row.type || '未分类'} : } + {tab === 'region' ? {inventoryTypeLabel(row.type) || '未分类'} : } + } - {tab === 'region' ? : <>{row.region}{row.city}} + {tab === 'model' && regionSpan > 0 && {row.region || '未分配'} item.region === row.region)} label={row.region || '未分配'} />} + {tab === 'region' ? : row.city || '未分配'} selectInventoryRow(row, select)} /> @@ -62,14 +72,14 @@ export function InventoryDesktopTable(props: InventoryListProps) { {tab === 'region' && 区域 / 城市} 品牌 {tab === 'region' && 车型} - 类别 / 颜色 / 归属方 - {tab === 'model' && 区域 / 城市} + 类别 + {tab === 'model' && <>区域城市} 库存数量 库存占比 {groups.map(group => - - + +
+ {view.parents.map((parent, index) => + )} + } +
+

{labels[view.dimension]}库存 · {view.groups.length} 项

+ {view.parents.length > 0 && } +
+
{labels[view.dimension]}库存(台)
+
    + {pageGroups.map(item =>
  • + + openVehicles(item.rows, item.label)} /> + {item.rows.some(row => row.abnormal || row.pending) &&
    } +
  • )} +
+
当前范围合计{inventoryTotal(view.rows).toLocaleString()} 台
+ {pageCount > 1 && } +
; +} export function InventoryMobileList(props: InventoryListProps) { - const { groups, tab, expanded, toggle, total, select } = props; + const { groups, tab, total } = props; + const [activeGroup, setActiveGroup] = useState(null); + const id = useId(); return
- {groups.map(group =>
+

点击名称逐层查看,点击数量查看车辆

+ {groups.map((group, index) =>
- 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/InventoryStatisticsSection.tsx b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx index ad0ca91..c899edf 100644 --- a/src/modules/assets/components/overview/InventoryStatisticsSection.tsx +++ b/src/modules/assets/components/overview/InventoryStatisticsSection.tsx @@ -43,7 +43,7 @@ export function InventoryStatisticsSection(props: InventoryStatisticsSectionProp
-

库存统计

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

+

库存统计

按区域、车型核对库存分布

- - {groups.length ? <> : + {groups.length ? <> :
}
共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存占比基于当前筛选结果 · 单位:台
diff --git a/src/modules/assets/components/overview/InventoryStatusCounts.tsx b/src/modules/assets/components/overview/InventoryStatusCounts.tsx index cd8a791..3715988 100644 --- a/src/modules/assets/components/overview/InventoryStatusCounts.tsx +++ b/src/modules/assets/components/overview/InventoryStatusCounts.tsx @@ -6,7 +6,7 @@ export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0); if (abnormal === 0 && pending === 0) return null; return - {abnormal > 0 && 其中异动 {abnormal} 台} - {pending > 0 && 其中待交车 {pending} 台} + {abnormal > 0 && 其中异动 {abnormal} 台} + {pending > 0 && 其中待交车 {pending} 台} ; } diff --git a/src/modules/assets/components/overview/inventory-presentation.test.ts b/src/modules/assets/components/overview/inventory-presentation.test.ts index b3546d7..22ff92a 100644 --- a/src/modules/assets/components/overview/inventory-presentation.test.ts +++ b/src/modules/assets/components/overview/inventory-presentation.test.ts @@ -1,11 +1,126 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { buildVehicleModalRequest, groupInventoryByRegion } from '../../model'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { InventoryStatusCounts } from './InventoryStatusCounts'; +import { buildVehicleModalRequest, deriveInventoryView, groupInventoryByRegion, type VehicleModalSelection } from '../../model'; +import { selectInventoryGroup, selectInventoryRow } from './InventoryDesktopTable'; import type { RegionalInventoryStats } from '../../types'; -import { inventoryBrandGroups, inventoryGroups, inventoryModelTags, inventorySelection } from './inventory-presentation'; +import { inventoryBrandGroups, inventoryGroups, inventoryHierarchy, inventoryMobileDrilldown, inventoryMobileGroups, inventoryMobileRows, inventoryModelTags, inventoryPage, inventoryRegionRows, inventorySelection, inventorySubtotalSelection } from './inventory-presentation'; +import { inventoryKey } from '../../../../shared/assets/inventory'; +import { fetchVehicleList } from '../../api'; const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' }; +test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => { + const rows = [ + { ...filters, batch: '', quantity: 9 }, + { ...filters, brand: '品牌乙', batch: '', quantity: 8 }, + { ...filters, type: '49T', batch: '', quantity: 3 }, + { ...filters, city: '苏州', batch: '', quantity: 2 }, + ]; + const cities = inventoryMobileDrilldown(rows, 'region', []); + assert.equal(cities.dimension, 'city'); + assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 20], ['苏州', 2]]); + const types = inventoryMobileDrilldown(rows, 'region', ['上海']); + assert.equal(types.dimension, 'type'); + assert.deepEqual(types.groups.map(group => [group.label, group.quantity]), [['18T', 17], ['49T', 3]]); + const variants = inventoryMobileDrilldown(rows, 'region', ['上海', '18T']); + assert.equal(variants.dimension, 'variant'); + assert.deepEqual(variants.groups.map(group => group.quantity), [9, 8]); + assert.deepEqual(variants.groups.flatMap(group => inventorySubtotalSelection(group.rows, group.label).inventoryKeys), rows.slice(0, 2).map(inventoryKey)); + assert.deepEqual(inventoryMobileDrilldown(rows, 'region', ['上海']).groups, types.groups, 'back navigation restores the whole parent'); +}); + +test('mobile model navigation isolates same-named cities by region and recovers invalid paths', () => { + const rows = [ + { ...filters, batch: '', quantity: 4 }, + { ...filters, region: '华南', batch: '', quantity: 7 }, + { ...filters, region: '华南', city: '', batch: '', quantity: 1 }, + ]; + assert.equal(inventoryMobileDrilldown(rows, 'model', []).dimension, 'region'); + const cities = inventoryMobileDrilldown(rows, 'model', ['华南']); + assert.deepEqual(cities.groups.map(group => [group.label, group.quantity]), [['上海', 7], ['未分配城市', 1]]); + assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '上海']).rows, [rows[1]]); + assert.deepEqual(inventoryMobileDrilldown(rows, 'model', ['华南', '']).rows, [rows[2]]); + assert.equal(inventoryMobileDrilldown(rows, 'model', ['华南', '已移除城市']).dimension, 'city'); + assert.equal(inventoryMobileDrilldown(rows, 'model', ['已移除区域']).dimension, 'region'); + assert.deepEqual(inventoryMobileDrilldown([], 'model', []).groups, []); +}); + +test('mobile type totals combine brands and other types without merging distinct variants', () => { + const rows = [ + { ...filters, type: '挂车', brand: '帕力安牌', batch: '一批', quantity: 3 }, + { ...filters, type: '其他', brand: '帕力安', batch: '二批', quantity: 4 }, + ]; + assert.deepEqual(inventoryMobileGroups(rows, 'type').map(group => [group.label, group.quantity]), [['其他', 7]]); + const view = inventoryMobileDrilldown(rows, 'region', ['上海', '其他']); + assert.equal(view.groups.length, 2); + assert.deepEqual(view.groups.flatMap(group => group.rows), rows); +}); + +test('subtotal drilldown sends the exact filtered records, including merged brand aliases and vehicle types', async (t) => { + const rows = [ + { ...filters, brand: '帕力安牌', type: '挂车', batch: '', quantity: 2 }, + { ...filters, brand: '帕力安', type: '其他', model: '其他型号', batch: '', quantity: 1 }, + ]; + const selection = inventorySubtotalSelection(rows, '其他'); + const request = buildVehicleModalRequest(selection, '公司甲'); + assert.equal(request.kind, 'vehicles'); + if (request.kind !== 'vehicles') throw new Error('wrong endpoint'); + assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey)); + assert.equal(request.params.subject, '公司甲'); + assert.equal(request.params.category, 'Inventory'); + assert.equal(request.params.brand, undefined, 'a merged alias must not restrict the scope to one spelling'); + t.mock.method(globalThis, 'fetch', async (url: string, init?: RequestInit) => { + assert.equal(init?.method, 'POST'); + assert.deepEqual(JSON.parse(String(init?.body)).inventoryKeys, rows.map(inventoryKey)); + assert.equal(new URL(url, 'http://localhost').searchParams.get('subject'), '公司甲'); + return new Response('[]', { status: 200 }); + }); + await fetchVehicleList(request.params); +}); + +test('hierarchy subtotals stay inside their parent and include records beyond the current page', () => { + const base = { ...filters, batch: '', quantity: 9 }; + const rows = [base, { ...base, model: '批次二', quantity: 8 }, { ...base, type: '49T', quantity: 5 }, { ...base, brand: '品牌乙', quantity: 3 }, { ...base, city: '苏州', quantity: 2 }]; + assert.deepEqual(inventoryHierarchy(base, rows, 'region').map(level => level.quantity), [25, 22, 17]); + const modelRows = rows.filter(row => row.type === '18T'); + assert.deepEqual(inventoryHierarchy(base, modelRows, 'model').map(level => level.quantity), [19, 11, 11]); + assert.deepEqual(inventoryHierarchy(base, [base], 'region').map(level => level.quantity), [9, 9, 9]); +}); + +test('region merging brings separated cities together without losing their quantities or scope', () => { + const rows = [ + { ...filters, region: '华南', city: '广州', batch: '', quantity: 25 }, + { ...filters, batch: '', quantity: 9 }, + { ...filters, region: '华南', city: '佛山', batch: '', quantity: 1 }, + ]; + const merged = inventoryRegionRows(rows); + assert.deepEqual(merged.map(({ row, regionSpan }) => [row.city, row.quantity, regionSpan]), [['广州', 25, 2], ['佛山', 1, 0], ['上海', 9, 1]]); + assert.deepEqual(rows.map(row => row.city), ['广州', '上海', '佛山']); + assert.equal(merged[1].row, rows[2]); +}); + +test('mobile pages cover every record once and clamp after a filter reduces the rows', () => { + const rows = Array.from({ length: 19 }, (_, index) => ({ ...filters, model: `型号${index}`, batch: '', quantity: index + 1 })); + const group = inventoryGroups(groupInventoryByRegion(rows), 'default')[0]; + const ordered = inventoryMobileRows(group, 'region'); + const pages = Array.from({ length: 4 }, (_, index) => inventoryPage(ordered, index)); + assert.deepEqual(pages.map(page => page.rows.length), [6, 6, 6, 1]); + assert.deepEqual(pages.flatMap(page => page.rows), rows); + assert.equal(inventoryPage(rows.slice(0, 2), 3).page, 0); + assert.deepEqual(inventoryPage([], 1), { page: 0, pageCount: 1, start: 0, rows: [] }); +}); + +test('inventory status hints show nonzero subset counts and hide zeros', () => { + const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 }; + const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [row] })); + assert.match(html, /其中待交车 2<\/strong> 台/); + assert.match(html, /其中异动 1<\/strong> 台/); + assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), ''); +}); + test('filtered inventory totals retain all filters in the vehicle request', () => { const request = buildVehicleModalRequest(inventorySelection(filters, '筛选结果'), '公司甲'); assert.deepEqual(request, { kind: 'vehicles', params: { @@ -44,7 +159,7 @@ test('model tags separate type and qualifiers without inventing a batch', () => { 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), ['其他', '挂车']); assert.deepEqual(inventoryModelTags({ type: '', model: '未知型号(未闭合', batch: '' }).map(tag => tag.value), ['未知型号(未闭合']); }); @@ -107,3 +222,29 @@ test('model grouping merges locations but keeps different brands, colors and bat assert.equal(inventorySelection(row, '明细').location, row.city); } }); + +test('inventory other includes trailers in groups, filters and drilldown while retaining exact row types', () => { + const trailers = { ...filters, brand: '明威', type: '挂车', model: '挂车', batch: '挂车', quantity: 3, pending: 1 }; + const other = { ...filters, brand: '其他', type: '其他', model: '氢能叉车', batch: '', quantity: 2, abnormal: 1 }; + const standard = { ...filters, type: '18T', batch: '', quantity: 10 }; + const filter = { region: '', city: '', brand: '', type: '其他', model: '' }; + const view = deriveInventoryView([trailers, other, standard], filter, '其他'); + assert.deepEqual(view.filtered, [trailers, other]); + assert.deepEqual(view.types, ['18T', '其他']); + assert.deepEqual(view.modelsForType, ['挂车', '氢能叉车']); + assert.deepEqual(Object.keys(view.byModel), ['其他']); + const group = inventoryGroups(view.byModel, 'default', 'model')[0]; + assert.equal(group.quantity, 5); + assert.equal(group.rows.reduce((sum, row) => sum + (row.pending || 0), 0), 1); + assert.equal(group.rows.reduce((sum, row) => sum + (row.abnormal || 0), 0), 1); + let selected: VehicleModalSelection | null = null; + const select = (value: VehicleModalSelection | null | ((previous: VehicleModalSelection | null) => VehicleModalSelection | null)) => { selected = typeof value === 'function' ? value(selected) : value; }; + selectInventoryGroup(group, { tab: 'model', filters: filter, select }); + assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '库存其他'); + assert.equal(inventorySelection(filter, '筛选后库存').vehicleType, '库存其他'); + selectInventoryRow(trailers, select); + assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '挂车'); + assert.equal((selected as VehicleModalSelection | null)?.model, '挂车'); + selectInventoryRow(other, select); + assert.equal((selected as VehicleModalSelection | null)?.vehicleType, '其他'); +}); diff --git a/src/modules/assets/components/overview/inventory-presentation.ts b/src/modules/assets/components/overview/inventory-presentation.ts index 6416dc8..df52d8a 100644 --- a/src/modules/assets/components/overview/inventory-presentation.ts +++ b/src/modules/assets/components/overview/inventory-presentation.ts @@ -1,5 +1,6 @@ -import type { InventoryFilters, VehicleModalSelection } from '../../model'; +import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model'; import type { RegionalInventoryStats } from '../../types'; +import { inventoryKey } from '../../../../shared/assets/inventory'; const BRAND_ORDER = ['现代', '帕力安', '宇通', '飞驰', '跃进']; const normalizeBrand = (brand: string) => brand.trim().replace(/牌$/, ''); @@ -21,7 +22,7 @@ export function inventoryTotal(rows: RegionalInventoryStats[]) { /** 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 type = inventoryTypeLabel(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(); @@ -47,11 +48,21 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove return { batch: 'All', model: filters.model || 'All', location: filters.city || filters.region || 'All', - brand: filters.brand || undefined, vehicleType: filters.type || undefined, + brand: filters.brand || undefined, vehicleType: inventoryTypeLabel(filters.type) === '其他' ? '库存其他' : filters.type || undefined, category: 'Inventory', source: 'asset', title, ...overrides, }; } +export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): VehicleModalSelection { + const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : ''; + const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · '); + return { + batch: 'All', model: 'All', location: 'All', source: 'asset', category: 'Inventory', + inventoryKeys: [...new Set(rows.map(inventoryKey))], + title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`, + }; +} + export interface InventoryDisplayGroup { name: string; sections: [string, RegionalInventoryStats[]][]; @@ -59,6 +70,19 @@ export interface InventoryDisplayGroup { quantity: number; } +/** Keep each category's regions contiguous so only the first city renders its region cell. */ +export function inventoryRegionRows(rows: RegionalInventoryStats[]) { + const regions = new Map(); + for (const row of rows) { + const records = regions.get(row.region) || []; + records.push(row); + regions.set(row.region, records); + } + return [...regions.values()].flatMap(records => records.map((row, index) => ({ + row, regionSpan: index === 0 ? records.length : 0, + }))); +} + /** Group presentation only: keep each original record for quantity and drilldown. */ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 'type' | 'model') { const brands = new Map }>(); @@ -70,7 +94,7 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't brands.set(key, brand); } brand.rows.push(row); - const variantKey = groupBy === 'type' ? row.type : JSON.stringify([row.type, row.model, row.batch]); + const variantKey = groupBy === 'type' ? inventoryTypeLabel(row.type) : JSON.stringify([row.type, row.model, row.batch]); const variant = brand.variants.get(variantKey) || []; variant.push(row); brand.variants.set(variantKey, variant); @@ -79,6 +103,67 @@ export function inventoryBrandGroups(rows: RegionalInventoryStats[], groupBy: 't .map(brand => ({ ...brand, variants: [...brand.variants.values()] })); } +export function inventoryMobileRows(group: InventoryDisplayGroup, tab: 'region' | 'model') { + const sections = tab === 'region' ? group.sections : [[group.name, group.rows] as const]; + return sections.flatMap(([, rows]) => inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model') + .flatMap(brand => brand.variants.flatMap(variants => tab === 'model' + ? inventoryRegionRows(variants).map(({ row }) => row) : variants))); +} + +/** Each level is a subtotal within its parent, across all pages of the filtered list. */ +export function inventoryHierarchy(row: RegionalInventoryStats, rows: RegionalInventoryStats[], tab: 'region' | 'model') { + const cityRows = rows.filter(item => item.region === row.region && item.city === row.city); + const brandRows = (tab === 'region' ? cityRows : rows).filter(item => normalizeBrand(item.brand) === normalizeBrand(row.brand)); + const variantRows = brandRows.filter(item => tab === 'region' + ? inventoryTypeLabel(item.type) === inventoryTypeLabel(row.type) + : item.type === row.type && item.model === row.model && item.batch === row.batch); + const levels = tab === 'region' + ? [{ label: row.city || '未分配', rows: cityRows }, { label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryTypeLabel(row.type) || '未分类', rows: variantRows }] + : [{ label: row.brand || '未分配品牌', rows: brandRows }, { label: inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') || '未分类', rows: variantRows }, { label: row.region || '未分配', rows: variantRows.filter(item => item.region === row.region) }]; + return levels.map(level => ({ ...level, quantity: inventoryTotal(level.rows) })); +} + +export type InventoryMobileDimension = 'region' | 'city' | 'type' | 'variant'; + +/** One dimension per screen. Every child retains exact records from its parent. */ +export function inventoryMobileGroups(rows: RegionalInventoryStats[], dimension: InventoryMobileDimension) { + const groups = new Map(); + for (const row of rows) { + const key = dimension === 'variant' + ? JSON.stringify([normalizeBrand(row.brand), row.type, row.model, row.batch]) + : dimension === 'type' ? inventoryTypeLabel(row.type) : row[dimension]; + const qualifiers = dimension === 'variant' ? inventoryModelTags(row).filter(tag => tag.kind !== 'type').map(tag => tag.value).join(' · ') : ''; + const label = dimension === 'variant' + ? [row.brand || '未分配品牌', qualifiers || row.model || '未分配型号'].join(' · ') + : key || (dimension === 'type' ? '未分类车型' : `未分配${dimension === 'city' ? '城市' : '区域'}`); + const group = groups.get(key) || { key, label, rows: [] }; + group.rows.push(row); + groups.set(key, group); + } + return [...groups.values()].map(group => ({ ...group, quantity: inventoryTotal(group.rows) })); +} + +export function inventoryMobileDrilldown(rows: RegionalInventoryStats[], tab: 'region' | 'model', path: string[]) { + const dimensions: InventoryMobileDimension[] = tab === 'region' ? ['city', 'type', 'variant'] : ['region', 'city', 'variant']; + const parents: ReturnType = []; + let currentRows = rows; + for (const key of path.slice(0, dimensions.length - 1)) { + const parent = inventoryMobileGroups(currentRows, dimensions[parents.length]).find(group => group.key === key); + if (!parent) break; + parents.push(parent); + currentRows = parent.rows; + } + const dimension = dimensions[parents.length]; + return { parents, rows: currentRows, dimension, groups: inventoryMobileGroups(currentRows, dimension) }; +} + +export function inventoryPage(rows: T[], requestedPage: number) { + const pageSize = 6; + const pageCount = Math.max(1, Math.ceil(rows.length / pageSize)); + const page = Math.max(0, Math.min(requestedPage, pageCount - 1)); + return { page, pageCount, start: page * pageSize, rows: rows.slice(page * pageSize, (page + 1) * pageSize) }; +} + 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]) => [ diff --git a/src/modules/assets/components/overview/inventory-statistics.css b/src/modules/assets/components/overview/inventory-statistics.css index dfedcb1..14e42ba 100644 --- a/src/modules/assets/components/overview/inventory-statistics.css +++ b/src/modules/assets/components/overview/inventory-statistics.css @@ -7,14 +7,24 @@ --inventory-accent: #2458cf; --inventory-accent-soft: #eff5ff; --inventory-warning: #a34d0a; + --inventory-warning-soft: #fff7e8; + --inventory-primary: #224baa; + --inventory-primary-hover: #193d94; + --inventory-accent-line: #c8d9f6; + --inventory-group: #f2f6fc; + --inventory-detail: #675086; + --inventory-detail-soft: #f6f2fb; + --inventory-detail-line: #e5dcf0; + --inventory-model: #226b69; + --inventory-model-soft: #f0f8f7; min-width: 0; margin-bottom: 24px; overflow: hidden; border: 1px solid var(--inventory-line); - border-radius: 16px; + border-radius: 14px; background: var(--inventory-bg); color: var(--inventory-text); - box-shadow: 0 2px 6px rgb(15 23 42 / 3%); + box-shadow: 0 4px 18px rgb(23 36 59 / 4%); font-size: 12px; line-height: 1.5; } @@ -23,9 +33,9 @@ .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-heading { padding: 12px 16px 10px; } .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-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border-radius: 9px; color: var(--inventory-accent); background: var(--inventory-accent-soft); border: 1px solid var(--inventory-accent-line); } .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; } @@ -36,19 +46,25 @@ .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-metrics { display: grid; grid-template-columns: 1.25fr 1fr 1fr; gap: 8px; margin: 0 16px; } +.inventory-metric { position: relative; padding: 8px 12px; text-align: left; min-width: 0; border: 1px solid var(--inventory-line); border-radius: 9px; background: var(--inventory-soft); } .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-summary-note { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px 12px; padding: 6px 16px 10px; font-size: 12px; color: var(--inventory-muted); } +.inventory-metric-primary { background: var(--inventory-primary); border-color: var(--inventory-primary); } +.inventory-metric-primary > span:first-child, .inventory-metric-primary strong, .inventory-metric-primary small { color: #fff; } +.inventory-metric-primary > span:first-child > svg { padding: 2px; width: 20px; height: 20px; border-radius: 5px; background: rgb(255 255 255 / 14%); } +.inventory-metric-pending:not(:disabled) { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); } +.inventory-metric-pending:not(:disabled) strong { color: var(--inventory-accent); } +.inventory-metric-warning:not(:disabled) { background: var(--inventory-warning-soft); } .inventory-metric-warning:not(:disabled) strong { color: var(--inventory-warning); } +.inventory-metric:disabled:not(.inventory-metric-primary) strong { color: var(--inventory-muted); } .inventory-metric:disabled > span > svg { visibility: hidden; } -.inventory-metric:not(:disabled):hover { background: var(--inventory-accent-soft); } +.inventory-metric:not(:disabled):hover { border-color: var(--inventory-accent); box-shadow: 0 2px 6px rgb(23 36 59 / 8%); } +.inventory-metric-primary:not(:disabled):hover { background: var(--inventory-primary-hover); } .inventory-metric:focus-visible { outline-offset: -4px !important; } +.inventory-metric-primary:focus-visible { outline-color: white !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; } @@ -61,35 +77,40 @@ .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 { display: inline-flex; padding: 3px; background: var(--inventory-group); border: 1px solid var(--inventory-line); border-radius: 8px; } .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-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); color: var(--inventory-accent); box-shadow: 0 1px 3px rgb(15 23 42 / 10%), inset 0 0 0 1px var(--inventory-accent-line); 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 thead th { font-weight: 600; 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="model"] thead th:nth-child(3) { width: 10%; } +.inventory-table[data-group-by="model"] thead th:nth-child(4) { width: 16%; } .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 { background: var(--inventory-group); } +.inventory-group-row[data-expanded="true"] { background: var(--inventory-accent-soft); } +.inventory-group-row[data-expanded="true"] > th { box-shadow: inset 3px 0 var(--inventory-accent); } .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-expand > span { font-size: 12px; font-weight: 400; color: var(--inventory-muted); padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 5px; background: var(--inventory-bg); } +.inventory-quantity { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; padding: 0 6px; min-width: 52px; 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:not(:disabled):hover, .inventory-quantity:focus-visible { background: var(--inventory-accent-soft); text-decoration: underline; text-underline-offset: 4px; } +.inventory-group-row .inventory-quantity { background: var(--inventory-bg); box-shadow: inset 0 0 0 1px var(--inventory-accent-line); } +.inventory-group-row .inventory-quantity:not(:disabled):hover { background: var(--inventory-primary); color: white; } .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; } @@ -97,19 +118,32 @@ .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:hover, .inventory-detail-row:focus-within { 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-row .inventory-city-cell { padding-left: 36px; background: var(--inventory-soft); font-weight: 600; } +.inventory-detail-row .inventory-region-cell { color: var(--inventory-muted); } +.inventory-subtotal { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 4px; color: var(--inventory-muted); font-size: 12px; font-weight: 400; } +.inventory-subtotal > span { white-space: nowrap; } +.inventory-subtotal strong { color: var(--inventory-text); font-weight: 650; font-variant-numeric: tabular-nums; } +.inventory-subtotal-button { display: inline-flex; align-items: center; gap: 4px; min-height: 24px; padding: 0 5px; border: 1px solid var(--inventory-accent-line); border-radius: 5px; background: var(--inventory-accent-soft); color: var(--inventory-accent); white-space: nowrap; } +.inventory-subtotal-button strong { color: inherit; } +.inventory-subtotal-button:hover { border-color: var(--inventory-accent); text-decoration: underline; text-underline-offset: 3px; } +.inventory-subtotal [data-testid="inventory-status-counts"] { flex-direction: column; align-items: flex-start; } +.inventory-subtotal-inline { display: inline-flex; margin: 0 0 0 8px; vertical-align: middle; } .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-tag { display: inline-block; max-width: 100%; padding: 1px 6px; border: 1px solid var(--inventory-line); border-radius: 5px; background: var(--inventory-soft); color: var(--inventory-text); font-size: 12px; font-weight: 500; line-height: 18px; overflow-wrap: anywhere; } +.inventory-tag-type { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); color: var(--inventory-accent); } +.inventory-tag-model { background: var(--inventory-model-soft); color: var(--inventory-model); } +.inventory-tag-detail, .inventory-tag-batch { background: var(--inventory-detail-soft); border-color: var(--inventory-detail-line); color: var(--inventory-detail); } .inventory-detail-row .inventory-share-track > span { opacity: .45; } -.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; } +.inventory-statistics [data-testid="inventory-status-counts"] { font-size: 12px; gap: 4px; } +.inventory-statistics [data-testid="inventory-status-counts"] > span { border-radius: 4px; padding: 1px 5px; white-space: nowrap; } +.inventory-statistics .inventory-status-abnormal { color: var(--inventory-warning); background: var(--inventory-warning-soft); } +.inventory-statistics .inventory-status-pending { color: var(--inventory-accent); background: var(--inventory-accent-soft); } .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-footer { flex-wrap: wrap; padding: 8px 16px; font-size: 12px; color: var(--inventory-muted); background: var(--inventory-soft); } .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; } @@ -118,46 +152,104 @@ .inventory-statistics button, .inventory-expand > svg { transition: background-color 150ms ease, color 150ms ease, transform 150ms ease; } } @media (max-width: 1023px) { + /* Match the shared SurfaceCard / SegmentedNav palette on mobile. */ + .inventory-statistics { + --inventory-text: #0f172a; + --inventory-muted: #64748b; + --inventory-line: #e2e8f0; + --inventory-accent: #1d4ed8; + --inventory-accent-soft: #eff6ff; + --inventory-accent-line: #dbeafe; + --inventory-group: #f8fafc; + --inventory-detail: #475569; + --inventory-detail-soft: #f8fafc; + --inventory-detail-line: #e2e8f0; + --inventory-model: #475569; + --inventory-model-soft: #f8fafc; + border-color: #f1f5f9; + border-radius: 16px; + box-shadow: 0 1px 3px rgb(15 23 42 / 8%); + } + .inventory-title-icon { border: 0; background: var(--inventory-accent-soft); } + .inventory-metric { background: var(--inventory-soft); border-color: #f1f5f9; border-radius: 12px; } + .inventory-metric-primary { background: var(--inventory-accent-soft); border-color: var(--inventory-accent-line); } + .inventory-metric-primary > span:first-child, .inventory-metric-primary small { color: var(--inventory-muted); } + .inventory-metric-primary strong { color: var(--inventory-accent); } + .inventory-metric-primary > span:first-child > svg { background: transparent; color: var(--inventory-accent); } + .inventory-metric-primary:not(:disabled):hover { background: #dbeafe; } + .inventory-metric-primary:focus-visible { outline-color: var(--inventory-accent) !important; } + .inventory-view-switch { border-color: #f1f5f9; background: var(--inventory-soft); border-radius: 12px; } + .inventory-view-switch button { border-radius: 9px; min-height: 44px; } + .inventory-view-switch button[aria-pressed="true"] { background: var(--inventory-bg); box-shadow: 0 1px 3px rgb(15 23 42 / 8%); } + .inventory-statistics select { border-radius: 10px; } + .inventory-statistics :is(.inventory-control, .inventory-text-button, .inventory-icon-button) { min-height: 44px; border-radius: 10px; } + .inventory-icon-button { min-width: 44px; } .inventory-desktop { display: none; } + .inventory-expand-all { 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-hint { color: var(--inventory-muted); font-size: 12px; } + .inventory-mobile-group { border: 1px solid var(--inventory-line); border-radius: 12px; overflow: hidden; background: var(--inventory-bg); } + .inventory-mobile-group[data-expanded="true"] { border-color: var(--inventory-accent-line); } + .inventory-mobile-group-heading > .inventory-quantity { padding-inline: 8px; min-width: 60px; font-size: 16px; } + .inventory-mobile-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 10px 0; } + .inventory-mobile-group-heading .inventory-expand > span { padding: 0; border: 0; background: transparent; } + .inventory-mobile-group-heading .inventory-expand > .inventory-mobile-group-icon { display: grid; place-items: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: 9px; background: var(--inventory-soft); color: var(--inventory-muted); } + .inventory-mobile-group[data-expanded="true"] .inventory-expand > .inventory-mobile-group-icon { background: var(--inventory-accent-soft); color: var(--inventory-accent); } + .inventory-mobile-group-heading .inventory-expand strong { font-size: 14px; } .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-mobile-detail-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block: 6px; color: var(--inventory-muted); } + .inventory-mobile-detail-toolbar h3 { min-width: 0; color: var(--inventory-text); font-size: 13px; font-weight: 650; } + .inventory-mobile-detail-toolbar h3 > span { color: var(--inventory-muted); font-size: 12px; font-weight: 400; } + .inventory-mobile-detail-toolbar h3:focus-visible { outline: 2px solid var(--inventory-accent); outline-offset: 3px; } + .inventory-mobile-detail-toolbar .inventory-text-button { padding-inline: 0; } + .inventory-mobile-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 0 6px; color: var(--inventory-muted); font-size: 12px; padding-top: 4px; } + .inventory-mobile-breadcrumb button { min-height: 44px; min-width: 44px; color: var(--inventory-accent); text-align: left; } + .inventory-mobile-breadcrumb [aria-current] { font-weight: 600; color: var(--inventory-text); } + .inventory-mobile-list-heading, .inventory-mobile-scope-total { display: flex; justify-content: space-between; gap: 8px; padding: 6px 8px; background: var(--inventory-soft); color: var(--inventory-muted); font-size: 12px; border-radius: 6px; } + .inventory-mobile-scope-total { margin-block: 4px; } + .inventory-mobile-scope-total strong { font-variant-numeric: tabular-nums; color: var(--inventory-text); font-weight: 600; } + .inventory-mobile-records { list-style: none; margin: 0; padding: 0; } + .inventory-mobile-records li + li { border-top: 1px solid var(--inventory-line); } + .inventory-mobile-drill-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0 8px; padding-block: 2px; } + .inventory-mobile-drill-label { display: flex; flex: 1; align-items: center; gap: 8px; min-width: 0; min-height: 44px; padding: 6px 4px; border-radius: 6px; text-align: left; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } + .inventory-mobile-drill-label > svg { flex-shrink: 0; color: var(--inventory-muted); } + .inventory-mobile-drill-label:hover { background: var(--inventory-soft); } + .inventory-mobile-drill-row .inventory-mobile-status { width: 100%; padding: 0 4px 4px; } + .inventory-mobile-pagination { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block: 4px; border-top: 1px solid var(--inventory-line); color: var(--inventory-muted); } + .inventory-mobile-pagination button { display: inline-flex; align-items: center; justify-content: center; gap: 4px; min-height: 44px; min-width: 80px; border-radius: 6px; color: var(--inventory-accent); } + .inventory-mobile-pagination button:disabled { color: var(--inventory-muted); opacity: .5; } + .inventory-mobile-pagination button:not(:disabled):hover { background: var(--inventory-accent-soft); } + .inventory-mobile-pagination > span { font-variant-numeric: tabular-nums; font-weight: 600; } + .inventory-mobile-pagination button:not(:disabled) { background: var(--inventory-accent-soft); } + .inventory-mobile :is(.inventory-quantity, .inventory-expand) { min-height: 44px; } + .inventory-mobile-group-heading .inventory-expand { flex: 1; } .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-icon { width: 28px; height: 28px; border-radius: 9px; } + .inventory-title-icon svg { width: 16px; height: 16px; } .inventory-title h2 { font-size: 15px; } - .inventory-title p { max-width: 24ch; } + .inventory-title p { margin-top: 2px; } .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-metrics { margin: 0 12px; gap: 6px; grid-template-columns: repeat(3, minmax(0, 1fr)); } .inventory-metric { padding: 8px; } - .inventory-metric strong { font-size: 20px; } + .inventory-metric strong { font-size: 24px; font-weight: 700; } .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 { align-items: center; gap: 8px; padding: 8px 12px; } + .inventory-view-switch { flex: 1; min-width: 0; } + .inventory-view-switch button { flex: 1; min-height: 44px; padding-inline: 6px; white-space: nowrap; } + .inventory-toolbar .inventory-sort select { min-width: 0; width: 112px; min-height: 44px; padding: 4px 6px; font-size: 16px; border-color: transparent; background: var(--inventory-soft); } + .inventory-heading .inventory-control { min-height: 44px; } .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-mobile-group-heading { padding-inline: 10px; } .inventory-expand { gap: 6px; } .inventory-expand strong { font-size: 12px; } .inventory-expand > span { font-size: 12px; } diff --git a/src/modules/assets/model.ts b/src/modules/assets/model.ts index 42b3582..50ce2e2 100644 --- a/src/modules/assets/model.ts +++ b/src/modules/assets/model.ts @@ -6,7 +6,8 @@ import type { VehicleListItem, } from './types'; -const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '挂车', '其他']; +const INVENTORY_TYPE_ORDER = ['4.5T普货', '4.5T冷链', '18T', '49T', '其他']; +export const inventoryTypeLabel = (type: string) => type === '挂车' ? '其他' : type; const CHINESE_NUMBER_ORDER: Record = { 一: 1, 二: 2, @@ -81,6 +82,7 @@ export interface VehicleModalSelection { } export interface VehicleListRequestParams { + inventoryKeys?: string[]; brand?: string; batch?: string; model?: string; @@ -181,6 +183,7 @@ export function buildVehicleModalRequest( } const params: VehicleListRequestParams = {}; + if (selection.inventoryKeys) params.inventoryKeys = selection.inventoryKeys; if (selection.brand) params.brand = selection.brand; if (selection.vehicleType) params.vehicleType = selection.vehicleType; if (selection.batch !== 'All') params.batch = selection.batch; @@ -239,7 +242,7 @@ export function filterInventoryStats( (!filters.region || item.region === filters.region) && (!filters.city || item.city === filters.city) && (!filters.brand || item.brand === filters.brand) - && (!filters.type || item.type === filters.type) + && (!filters.type || inventoryTypeLabel(item.type) === inventoryTypeLabel(filters.type)) && (!filters.model || item.model === filters.model) )); } @@ -261,9 +264,10 @@ export function groupInventoryByModel( ): Record> { const raw: Record> = {}; for (const item of inventory) { - if (!raw[item.type]) raw[item.type] = {}; - if (!raw[item.type][item.model]) raw[item.type][item.model] = []; - raw[item.type][item.model].push(item); + const type = inventoryTypeLabel(item.type); + if (!raw[type]) raw[type] = {}; + if (!raw[type][item.model]) raw[type][item.model] = []; + raw[type][item.model].push(item); } // 已知车型按报表约定排序,新增的未知车型保留接口返回时的首次出现顺序。 @@ -284,9 +288,9 @@ export function deriveInventoryView( ) { const filtered = filterInventoryStats(inventory, filters); const modelSource = modelTypeFilter - ? inventory.filter((item) => item.type === modelTypeFilter) + ? inventory.filter((item) => inventoryTypeLabel(item.type) === inventoryTypeLabel(modelTypeFilter)) : inventory; - const types = uniqueNonEmpty(inventory.map((item) => item.type)); + const types = uniqueNonEmpty(inventory.map((item) => inventoryTypeLabel(item.type))); return { filtered, diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 9e42798..fc7140e 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -354,8 +354,16 @@ app.get('/customer-stats', async (c) => { return c.json(result); }); -// GET /api/vehicles/list — flat list with optional filters -app.get('/list', async (c) => { +// POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown. +app.on(['GET', 'POST'], '/list', async (c) => { + let inventoryKeys: Set | undefined; + if (c.req.method === 'POST') { + const body = await c.req.json().catch(() => null); + if (!body || !Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')) { + return c.json({ error: '库存明细范围无效' }, 400); + } + inventoryKeys = new Set(body.inventoryKeys); + } const vehicles = await getVehiclesForUser(c); const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); @@ -363,6 +371,10 @@ app.get('/list', async (c) => { ? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v)) : vehicles; + if (inventoryKeys) { + filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v)))); + } + // attendance filter: active = day_mileage > 0, idle = day_mileage = 0 (only for Operating vehicles) if (attendance === 'active' || attendance === 'idle') { const realtimeRows = await vehicleRepository.getAttendanceMileageRows(); diff --git a/src/server/routes/vehicles/drilldown.test.ts b/src/server/routes/vehicles/drilldown.test.ts index 4ef9911..3f03b76 100644 --- a/src/server/routes/vehicles/drilldown.test.ts +++ b/src/server/routes/vehicles/drilldown.test.ts @@ -78,3 +78,31 @@ test('inventory composition and color variants reconcile with permission-filtere assert.ok(list.every((vehicle: {inventoryKey:string}) => vehicle.inventoryKey === JSON.stringify([row.region,row.city,row.brand,row.type,row.model]))); assert.equal(list.filter((vehicle: {color:string}) => vehicle.color === '红').length,1); }); + +test('inventory subtotal scopes intersect actual stock, company and exact city/brand/model records', async (t) => { + const make = (id: string, overrides: Partial = {}): Vehicle => ({ + id, plateNumber: `TEST-${id}`, vin: id, type: '18T', model: '飞驰18T白车', color: '白', + location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市', + status: 'Inventory', operationStatus: '3', ownership: 'Self', rentCompany: '', + contractNo: null, customerName: null, orgName: null, departmentName: null, + subjectOrg: '公司甲', projectName: null, customerManager: null, managerId: null, + brandLabel: '飞驰', ...overrides, + }); + t.mock.method(vehicleRepository, 'getVehicles', async () => [ + make('1'), make('2', { model: '飞驰18T红车', status: 'Pending' }), + make('3', { city: '金华市' }), make('4', { brandLabel: '其他品牌' }), + make('5', { operationStatus: '1', status: 'Operating' }), make('6', { subjectOrg: '公司乙' }), + ]); + const stats = await (await app.request('/inventory-stats?subject=公司甲')).json(); + const keys = stats.filter((row: { city: string; brand: string }) => row.city === '嘉兴市' && row.brand === '飞驰') + .map((row: { region: string; city: string; brand: string; type: string; model: string }) => JSON.stringify([row.region, row.city, row.brand, row.type, row.model])); + const request = (inventoryKeys: unknown) => app.request('/list?source=asset&subject=公司甲&category=Inventory', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }), + }); + const response = await request(keys); + assert.equal(response.status, 200); + assert.deepEqual((await response.json()).map((row: { id: string }) => row.id), ['1', '2']); + assert.deepEqual(await (await request([])).json(), []); + assert.equal((await request(null)).status, 400); + assert.equal((await request([123])).status, 400); +}); diff --git a/src/server/routes/vehicles/model.test.ts b/src/server/routes/vehicles/model.test.ts index ae5eb00..5695ef1 100644 --- a/src/server/routes/vehicles/model.test.ts +++ b/src/server/routes/vehicles/model.test.ts @@ -116,6 +116,9 @@ test('keeps summary and list meanings of other vehicle types distinct', () => { assert.equal(summaryOther?.filter(trailer), true); assert.equal(VEHICLE_TYPE_FILTERS['其他'](trailer), false); assert.equal(VEHICLE_TYPE_FILTERS['其他'](forklift), true); + assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](trailer), true); + assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](forklift), true); + assert.equal(VEHICLE_TYPE_FILTERS['库存其他'](vehicle({ type: '18T', model: '苏龙18T飞翼' })), false); }); test('keeps location filtering semantics for the ambiguous other region', () => { diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index f793c3d..91b32f4 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -272,6 +272,7 @@ export const VEHICLE_TYPE_FILTERS: Record boolean> '49T': (vehicle) => vehicle.type === '49T', '挂车': (vehicle) => vehicle.type === '挂车' || vehicle.model.includes('挂车'), '其他': (vehicle) => classifyVehicleType(vehicle) === 'other', + '库存其他': (vehicle) => ['trailer', 'other'].includes(classifyVehicleType(vehicle)), }; export function filterByLocation(vehicles: Vehicle[], location: string, source?: string): Vehicle[] { diff --git a/src/server/routes/vehicles/repository.test.ts b/src/server/routes/vehicles/repository.test.ts index 7676e0d..ddd41f4 100644 --- a/src/server/routes/vehicles/repository.test.ts +++ b/src/server/routes/vehicles/repository.test.ts @@ -27,7 +27,9 @@ function vehicle(id: string): Vehicle { test('keeps all vehicle GET endpoints in their established order', () => { const source = readFileSync(new URL('../vehicles.ts', import.meta.url), 'utf8'); - const paths = Array.from(source.matchAll(/app\.get\('([^']+)'/g), (match) => match[1]); + const paths = Array.from( + source.matchAll(/app\.get\('([^']+)'|app\.on\(\[([^\]]+)\],\s*'([^']+)'/g), + ).flatMap((match) => match[1] ? [match[1]] : /'GET'/.test(match[2]) ? [match[3]] : []); assert.deepEqual(paths, [ '/summary', From dc0923b5663784bafd8e237c7dd4dd7ebc5dc209 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Wed, 23 Sep 2026 17:52:33 +0800 Subject: [PATCH 2/2] feat(asset-ai): generate contextual presentations and drilldown plans --- docs/asset-ai-boss-acceptance.md | 67 + docs/asset-ai-composition.md | 30 + src/modules/assets/AssetsModule.tsx | 49 +- .../assets/assistant/AssetAssistant.tsx | 1194 +++++++++------- .../assets/assistant/CompoundResultView.tsx | 302 ++++ .../assets/assistant/GenerativeResultView.tsx | 682 +++++++++ src/modules/assets/assistant/ResultView.tsx | 1271 ++++++++++------- .../assets/assistant/ThinkingState.tsx | 24 +- src/modules/assets/assistant/assistant.css | 442 +++++- .../conversation-presentation.test.ts | 60 + .../assistant/conversation-presentation.ts | 55 + .../assistant/generative-presentation.test.ts | 253 ++++ .../assistant/generative-presentation.ts | 129 ++ .../assistant/result-presentation.test.ts | 219 +++ .../assets/assistant/result-presentation.ts | 194 +++ .../asset-assistant/boss-experience.test.ts | 258 ++++ .../asset-assistant/composition.test.ts | 264 ++++ .../routes/asset-assistant/composition.ts | 40 + src/server/routes/asset-assistant/execute.ts | 5 + .../routes/asset-assistant/experience.test.ts | 14 +- .../asset-assistant/generated-action.ts | 36 + .../asset-assistant/generative-route.test.ts | 238 +++ .../routes/asset-assistant/generative.test.ts | 259 ++++ .../routes/asset-assistant/generative.ts | 228 +++ src/server/routes/asset-assistant/index.ts | 121 +- .../routes/asset-assistant/model.test.ts | 12 +- src/server/routes/asset-assistant/model.ts | 46 +- .../routes/asset-assistant/planner.test.ts | 323 +++++ src/server/routes/asset-assistant/planner.ts | 387 ++++- .../asset-assistant/presentation.test.ts | 25 + .../routes/asset-assistant/presentation.ts | 55 + src/server/routes/asset-assistant/session.ts | 7 +- src/shared/assets/assistant.ts | 69 +- 33 files changed, 6193 insertions(+), 1165 deletions(-) create mode 100644 docs/asset-ai-boss-acceptance.md create mode 100644 docs/asset-ai-composition.md create mode 100644 src/modules/assets/assistant/CompoundResultView.tsx create mode 100644 src/modules/assets/assistant/GenerativeResultView.tsx create mode 100644 src/modules/assets/assistant/conversation-presentation.test.ts create mode 100644 src/modules/assets/assistant/conversation-presentation.ts create mode 100644 src/modules/assets/assistant/generative-presentation.test.ts create mode 100644 src/modules/assets/assistant/generative-presentation.ts create mode 100644 src/modules/assets/assistant/result-presentation.test.ts create mode 100644 src/modules/assets/assistant/result-presentation.ts create mode 100644 src/server/routes/asset-assistant/boss-experience.test.ts create mode 100644 src/server/routes/asset-assistant/composition.test.ts create mode 100644 src/server/routes/asset-assistant/composition.ts create mode 100644 src/server/routes/asset-assistant/generated-action.ts create mode 100644 src/server/routes/asset-assistant/generative-route.test.ts create mode 100644 src/server/routes/asset-assistant/generative.test.ts create mode 100644 src/server/routes/asset-assistant/generative.ts create mode 100644 src/server/routes/asset-assistant/presentation.test.ts create mode 100644 src/server/routes/asset-assistant/presentation.ts diff --git a/docs/asset-ai-boss-acceptance.md b/docs/asset-ai-boss-acceptance.md new file mode 100644 index 0000000..d055cbe --- /dev/null +++ b/docs/asset-ai-boss-acceptance.md @@ -0,0 +1,67 @@ +# 老板视角问数验收 + +## 来源与判定原则 + +根据用户提供的《AI资产助手-老板视角实测-20260923.md》完整复测设计。原测试于 2026-09-23 16:11—16:14 在 beta 环境执行。它确认了一次会话的前端表现,不代表数据库审计或权限验收。 + +本轮优先纠正三个产品问题:待交车不能被待运营替代;复合问题先返回已支持部分;每张结果带足以独立理解的范围与口径。自动化测试使用合成数据和模拟模型,没有访问真实数据库或真实 DeepSeek API。部署后的实机复测必须另行记录,不能用模拟通过替代。 + +## 原始十问与目标 + +| 题号 | 原始输入 | 修复完成判定 | +| --- | --- | --- | +| 01 | 我是公司负责人。请用当前数据给我一份资产经营简报:资产总数、运营和库存各多少、运营率多少;哪个车型库存最多,最值得我优先关注?请给数字依据、数据截至时间和统计口径。 | 返回四个总量指标及库存车型集中度;重点关注是基于库存数量的建议,不编造滞销原因或损失。源更新时间缺失时明确未提供。 | +| 02 | 现在总共有多少台车?运营多少、库存多少,整体运营率是多少? | 四项都在结果指标中;数值由内部计算。 | +| 03 | 按车型看库存,按库存数量从多到少排序,告诉我库存最多的车型占总库存多少。 | 库存降序、占完整库存比例;说明吨位粒度,4.5T 包含普货与冷链。 | +| 04 | 这些4.5T库存主要在哪些区域?只看可运营的,按数量降序。 | 保留 4.5T、可运营、区域分组、降序;单独复制结果仍可识别完整范围。 | +| 05 | 换个问题:最近7天交车、还车分别多少?其中替换交车多少?比前7天是改善还是变差?请写明起止日期和统计口径。 | 清除旧 4.5T 和库存限制,返回最近七天与前七天数字、日期;改善方向缺少业务目标时仅澄清这一部分。不得把交车减还车直接认定为运营车辆净增。 | +| 06 | 全部车辆,不沿用之前4.5T或库存筛选。查询2026年9月17日至9月23日交车、还车、替换交车数量,按天列出。 | 日期完整保留、旧筛选清除;交车含替换且不重复相加,单位为车次。 | +| 07 | 当前库存比上个月月底增加了多少?这些闲置车每个月损失多少钱?没有相应数据请直接说明,不要估算。 | 说明缺历史库存快照与成本数据,无估算;不能回退到当前库存并假装已经回答历史变化。 | +| 08 | 现在全部公司的待交车有多少?列出待交车清单。 | 当前账号可见范围内待交车真实数量和明细;0 展示为空结果,不展示失败或停止。 | +| 09 | 查询待交车数量 | 与第 08 题同口径;即使模型误提议 awaiting,也不能执行待运营替代。 | +| 10 | 我问的是“待交车”,不是“待运营”。请区分这两个业务状态,重新查询待交车数量;如果不支持,请明确说明,不能用待运营数量替代。 | 重新查询待交车;若历史结果确实答错,明确指出该结果不适用于待交车判断并关联已纠正的结果。 | + +## 合成测试基线 + +10 台资产,4 台运营,6 台库存,运营率 40%。待运营 3 台,待交车 0 台,故意让两者不同,避免状态替代错误被相同数量掩盖。 + +库存:4.5T 普货 1 台、4.5T 冷链 3 台、18T 1 台、49T 1 台。4.5T 合并为 4 台,占库存 66.7%。其中 4.5T 可运营 2 台,均位于华东。 + +流转:每个请求期间返回合成交车 1 笔、替换 1 笔、还车 1 笔;交车含替换合计 2 车次,总事件 3 笔。用于验证日期、筛选、计数与跨期调用,并不代表业务趋势。 + +## 自动化与实机分工 + +`src/server/routes/asset-assistant/boss-experience.test.ts` 校验业务计划实际执行、错误模型状态纠正、复合部分回答、完整十问本地降级链路、结果口径、4.5T 进一步分类和错误答案撤回关联。 + +2026-09-23 开发验收:以上 9 项路由集成场景全部通过,`npm run lint` 通过。执行命令为 `node --import tsx --test src/server/routes/asset-assistant/boss-experience.test.ts`。其中另外加入“真实待交 1 台但模型残留待运营过滤”的反例,验证不会产生假零结果。此处只记录本地自动化结论,不代表 beta 环境已经部署或真实模型十问已经全部通过。 + +实机需重点补验: + +1. 使用原始十问直接询问真实模型,保留每题结果和实际查询计划。 +2. 验证待交车推荐入口、短问、长问、多轮纠正与首页同口径。 +3. 分别触发空结果、能力缺口、服务异常、主动停止,检查标题和操作不同。 +4. 复制第 04 题结果检查公司、权限、4.5T、可运营、分组及两种时间说明。 +5. 库存车型结果不再推荐同样的车型分组;冷链/普货入口可正常执行。 +6. 连续十轮后能找到每条提问,新答案顶部的结论和指标可见;桌面短高度和手机都要测试。 + +## 独立审阅关注点 + +- 待交车是业务流程状态;待运营是运营状态。确定性纠正既要改 category,也要清掉旧的状态过滤与错误 unsupported 原因,否则仍可能得到假零或错标题。 +- 综合简报的库存集中度必须在库存子集上计算,不能直接用全部资产按车型排序替代“库存最多”。 +- 部分回答的未完成事项应单独列出并进入需求记录,不能把“经营好坏需要目标”误报为“交退换查询不支持”。 +- 纠正旧答案需能定位原结果。正常把待运营查询切换为待交车时,不应无依据地声称先前答案错误。 +- “查询于”是生成时间;没有源刷新证据时 `sourceUpdatedAt` 应为空并以文字说明,不能填当前时间。 +- 普货/冷链分组必须复用首页分类,分组汇总与 4.5T 合计可核对。 + +## 最终开发验证(2026-09-23) + +- AI 专项:服务端 assistant 与前端 assistant 共 89 项测试通过,包含原十问、错误计划、真实非零待交车、部分回答、复制范围以及分组示例不误变筛选等反例。 +- 类型检查、生产构建、`git diff --check` 通过。构建仍有既有的大体积 chunk 提示。 +- 真实 DeepSeek 验证仅提供合成车辆目录与合成汇总:原十问初跑 8 次模型成功、Q1/Q5 本地降级。定位到模型省略展示字段的问题后,Q1 与 Q5 单独复测均由 DeepSeek 正常规划;这不是“一次连续十题全部模型成功”的证据,也不是生产数据验收。 +- 浏览器使用真实前端与真实问答路由、合成车辆和内存需求记录:完成原十问连续交互;10 条历史可回看,新回答定位到结论和指标;第 4 问复制包含区域数字、4.5T、可运营、公司、权限、口径及两种时间说明;浏览器无控制台错误。 +- 390 × 844 手机视口与桌面视口检查:经营简报四项指标与输入框可见;部分回答、库存明细入口、独立待交车统计可正常显示。最后单独确认 Q7 分别列出缺历史快照、缺成本数据,并记录未完成需求。 +- 尚未提交、部署。生产数据及实际部署后的交互须在发布后复测。 + +全量测试另有一项车辆路由声明顺序断言失败:并行工作将 `/list` 从 `app.get` 改为 `app.on(['GET', 'POST'], ...)`,原测试仍仅扫描 `app.get`。它不属于本轮 AI 修改,未覆盖或撤销该并行工作;AI 专项 89 项均通过。 + +本次改动与验证属于该项目的业务语义和展示优化,未形成跨项目知识候选,未修改全局开发规则。 diff --git a/docs/asset-ai-composition.md b/docs/asset-ai-composition.md new file mode 100644 index 0000000..8179737 --- /dev/null +++ b/docs/asset-ai-composition.md @@ -0,0 +1,30 @@ +# 资产 AI 自主组合分析 + +## 行为 + +问数分两阶段:先规划并查询,再把已计算的结果汇总交给 DeepSeek 设计展示和下一步交互。简单问题通常一次查询规划和一次展示规划;多项结果共用一次展示规划调用。复合问题由 DeepSeek 在一次 JSON 规划中拆成最多四项不同的查询,并选择指标卡、图表、统计表或明细;数据由既有后台查询和计算产生。模型不会生成可执行的 SQL、HTML 或 JavaScript。 + +例如“华东的4.5T库存有多少?待交车占库存多少?按城市展示库存分布,并列出哪些异动车辆即将到期”,可组合库存指标、城市库存图和即将到期异动明细。待交车、异动已包含在库存内,占比的分母是完整筛选范围的库存,不受图表 Top N 截断影响。零分母时明确不计算占比。 + +AI 根据问题与实际结果,自主决定信息块顺序、重点指标、柱图/环图/折线、表格列和下钻目标。多项分析可由 AI 选择分页切换或连续排列;前端保持 Web/H5 的紧凑尺寸与统一操作区。切换项目同时切换追问范围;点击分组原位更新所属项目,其他项目保留。每项均有自己的服务端会话结果 ID,可独立下钻、返回、导出和反馈。复制整份回答包含所有项目及其统计范围与口径。 + +无法生成有效计划的部分列入“尚未回答”;数据源执行失败的项目显示错误和单独重试入口。健康项目照常返回。缺口沿用原有需求记录存储,保存失败会如实提示。该记录用于产品需求改进,不会自动训练或修改模型。 + +## 协议与维护 + +规划层沿用单项 `plan`,可选 `queries: [{id,title,question,plan,display}]`;每项独立规范化和验证,避免整句的一个业务状态覆盖全部项目。普通单项响应维持兼容格式。 + +结果层增加 `sections: [{id,title,question,display,result?,error?}]`。每个成功的 `result` 仍使用既有结果协议。父分析和子分析均保留各项汇总上下文 `analyses`,供“第二项”“这些待交车”等追问选择对应分析。AI 下钻入口按服务端会话保存的目标查询执行,并绑定实际点击的分组或日期。每次下钻完成后重新设计展示和下一步入口;不再走固定的维度遍历顺序。 + +所有项目使用同一轮授权后的车辆快照。交还车事件仍按既有数据源查询;不同数据源不承诺事务级同时快照。导出会按当前权限重新查询,数据可能随业务更新变化。模型只选择现有数据与计算能力;财务损失、历史库存等未接入能力仍不能生成真实结论。 + +展示协议为 `presentation: {title, blocks, interactions}`。`blocks` 是有序的语义信息块,可配置 summary、narrative、metrics、chart、table;指标、列和图形序列绑定已有结果字段,事实数字由后台数据提供。叙述支持 `{{metric:指标名称}}` 引用。`interactions` 保存 AI 生成的目标查询和操作名称,客户端只提交入口 ID 与点击的数据点,后台再按当前权限查询。 + +AI 展示规划不可用时,保留查询结果并回退基础展示,明确提示回退。停止生成会取消后续展示规划。展示定义本身不会直接执行浏览器脚本,也不会生成数据库查询语句。 + +## 验证 + +- `src/server/routes/asset-assistant/composition.test.ts`:复合查询的授权范围、首项/次项故障隔离、单次车辆快照、跨项上下文、子项下钻、无效计划部分降级、占比分母和切换区域。 +- `src/server/routes/asset-assistant/planner.test.ts`:自主规划协议、独立业务语义、展示回退及过滤条件继承。 +- `src/modules/assets/assistant/conversation-presentation.test.ts`:子项原位替换、保留其他项目、历史范围恢复。 +- 已对真实 DeepSeek 接口使用合成车辆做单次复合规划验证,未使用生产车辆数据。 diff --git a/src/modules/assets/AssetsModule.tsx b/src/modules/assets/AssetsModule.tsx index 83b8c70..14f37b2 100644 --- a/src/modules/assets/AssetsModule.tsx +++ b/src/modules/assets/AssetsModule.tsx @@ -55,8 +55,40 @@ import { OverviewView } from './components/OverviewView'; const AssetAssistant = lazy(() => import('./assistant/AssetAssistant')); export default function AssetsModule() { - const [assistantOpen, setAssistantOpen] = useState(false); + const [assistantOpen, setAssistantOpen] = useState(() => window.location.hash === '#assets/assistant'); const assistantTrigger = useRef(null); + useEffect(() => { + const syncAssistantRoute = () => setAssistantOpen(window.location.hash === '#assets/assistant'); + window.addEventListener('hashchange', syncAssistantRoute); + window.addEventListener('popstate', syncAssistantRoute); + return () => { + window.removeEventListener('hashchange', syncAssistantRoute); + window.removeEventListener('popstate', syncAssistantRoute); + }; + }, []); + useEffect(() => { + if (!assistantOpen) return; + const previousTitle = document.title; + document.title = 'AI 问数 - 资产BI'; + return () => { document.title = previousTitle; }; + }, [assistantOpen]); + const openAssistant = () => { + if (window.location.hash !== '#assets/assistant') { + const { pathname, search } = window.location; + window.history.pushState({ ...window.history.state, assetAssistantPage: true }, '', `${pathname}${search}#assets/assistant`); + } + setAssistantOpen(true); + }; + const closeAssistant = () => { + if (window.history.state?.assetAssistantPage && window.location.hash === '#assets/assistant') { + window.history.back(); + } else { + const { pathname, search } = window.location; + window.history.replaceState(window.history.state, '', `${pathname}${search}#assets`); + setAssistantOpen(false); + } + requestAnimationFrame(() => requestAnimationFrame(() => assistantTrigger.current?.focus())); + }; const [activeTab, setActiveTab] = useState<'overview' | 'department' | 'region' | 'customer'>('overview'); const [tabReady, setTabReady] = useState(true); const prevTabRef = useRef(activeTab); @@ -431,6 +463,14 @@ export default function AssetsModule() { [customerData, customerChartView, customerProvinceData], ); + if (assistantOpen) { + return ( + 正在加载 AI 问数页面…}> + + + ); + } + if (loading && !summary) { return ( @@ -1710,16 +1750,13 @@ export default function AssetsModule() { - {assistantOpen && 正在打开 AI 问数…}> { setAssistantOpen(false); requestAnimationFrame(() => assistantTrigger.current?.focus()); }} />} ); } diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 943cb15..f94abb5 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ArrowUp, ArrowUpRight, @@ -17,13 +17,8 @@ import { Sun, Truck, X, + ArrowLeft, } from "lucide-react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, -} from "../../../components/ui/dialog"; import type { AssistantResult, AssistantAction, @@ -37,7 +32,8 @@ import { askAssetAssistant, AssistantRequestError } from "./client"; import { fetchAuthenticated } from "../../../auth/api-client"; import { DemandGaps } from "./DemandGaps"; import { ThinkingState } from "./ThinkingState"; -import { ResultView } from "./ResultView"; +import { CompoundResultView } from "./CompoundResultView"; +import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; import "./assistant.css"; type Turn = { @@ -46,6 +42,9 @@ type Turn = { started: number; steps: string[]; result?: AssistantResult; + resultHistory?: AssistantResult[]; + updating?: boolean; + updateError?: string; error?: string; expired?: boolean; stopped?: boolean; @@ -55,26 +54,26 @@ type Turn = { const PROMPTS = [ { icon: BarChart3, - title: "库存主要集中在哪里?", - text: "各区域的库存车辆有多少?按数量排序。", - detail: "看分布,再逐层查看车辆", + title: "现在多少车在运营、多少车在库存?", + text: BUSINESS_QUESTIONS[0], + detail: "总资产 · 运营 · 库存 · 运营率", }, { icon: Truck, - title: "哪些车型运营率较低?", - text: "按车型对比当前资产数量和运营率。", - detail: "车型构成 · 运营率", + title: "库存集中在哪些车型和区域?", + text: BUSINESS_QUESTIONS[1], + detail: "库存分布 · 继续查看车辆", }, { icon: History, - title: "本月车辆流转怎么样?", - text: "本月的交车、还车和替换交车趋势怎么样?", + title: "最近7天交车、还车分别多少?", + text: BUSINESS_QUESTIONS[2], detail: "按日趋势 · 业务明细", }, { icon: ListIcon, title: "有哪些车辆需要跟进?", - text: "查看当前异动车辆,显示异动类型和预计完成时间。", + text: BUSINESS_QUESTIONS[3], detail: "异动详情 · 预计完成时间", }, ]; @@ -93,6 +92,7 @@ export default function AssetAssistant({ }) { const [theme, setTheme] = useState(appearance); const [turns, setTurns] = useState([]); + const turnsRef = useRef(turns); const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [expandedTurns, setExpandedTurns] = useState>( @@ -114,18 +114,66 @@ export default function AssetAssistant({ const nextId = useRef(0); const composer = useRef(null); const scroller = useRef(null); + const autoFollow = useRef(null); + const readerRevision = useRef(0); + const scrollFrame = useRef(null); + const [viewedTurn, setViewedTurn] = useState(); + useLayoutEffect(() => { + const input = composer.current; + if (!input) return; + input.style.height = "0px"; + input.style.height = `${Math.min(132, Math.max(42, input.scrollHeight))}px`; + }, [draft]); + useEffect(() => () => { + if (scrollFrame.current !== null) cancelAnimationFrame(scrollFrame.current); + }, []); + const scrollToTurn = (id: number, resultId?: string) => { + const container = scroller.current; + const target = (resultId ? document.getElementById(`aq-query-${resultId}`) || document.getElementById(`aq-answer-${resultId}`) : undefined) || document.getElementById(`aq-turn-${id}`); + if (!container || !target) return; + const anchor = resultId ? target.closest(".aq-result") || target : target; + container.scrollTo({ top: container.scrollTop + anchor.getBoundingClientRect().top - container.getBoundingClientRect().top - 12, behavior: "instant" }); + setViewedTurn(id); + }; + const trackViewedTurn = () => { + if (scrollFrame.current !== null) return; + scrollFrame.current = requestAnimationFrame(() => { + scrollFrame.current = null; + const container = scroller.current; + if (!container) return; + const top = container.getBoundingClientRect().top + 24; + const visible = turnsRef.current.find(turn => { + const el = document.getElementById(`aq-turn-${turn.id}`); + return el && el.getBoundingClientRect().bottom > top; + }); + if (visible) setViewedTurn(visible.id); + }); + }; + useEffect(() => { + turnsRef.current = turns; + }, [turns]); const scopeResult = activeResult?.plan.kind === "clarify" ? activeResult.breadcrumbs ?.map( (crumb) => - turns.find((turn) => turn.result?.resultId === crumb.resultId) - ?.result, + findConversationResult(turns, crumb.resultId), ) .filter((result) => result && result.plan.kind !== "clarify") .at(-1) : activeResult; const lastPlan = scopeResult?.plan; + const correctedBy = useMemo(() => { + const corrections = new Map(); + for (const turn of turns) { + const previousId = turn.result?.correction ? turn.result.correction.resultId || turn.baseResultId : undefined; + if (previousId) corrections.set(previousId, turn); + } + return corrections; + }, [turns]); + useEffect(() => { + composer.current?.focus({ preventScroll: true }); + }, []); useEffect(() => { const controller = new AbortController(); void fetchAuthenticated("/api/asset-assistant/capabilities", { @@ -143,13 +191,12 @@ export default function AssetAssistant({ const revealTurn = (id: number) => { setShowGaps(false); setExpandedTurns((old) => new Set(old).add(id)); - requestAnimationFrame(() => - document - .getElementById(`aq-turn-${id}`) - ?.scrollIntoView({ block: "start", behavior: "smooth" }), - ); + autoFollow.current = null; + setViewedTurn(id); + requestAnimationFrame(() => scrollToTurn(id, turnsRef.current.find(turn => turn.id === id)?.result?.resultId)); }; const startTopic = () => { + autoFollow.current = null; setActiveResult(undefined); sessionRef.current = undefined; setIndependent(true); @@ -167,13 +214,14 @@ export default function AssetAssistant({ [], ); const stop = () => { + autoFollow.current = null; abortRef.current?.abort(); abortRef.current = null; setBusy(false); setTurns((previous) => previous.map((t) => - !t.result && !t.error - ? { ...t, error: "已停止。可以修改问题,或继续生成。", stopped: true } + t.updating ? { ...t, updating: false, updateError: "已停止更新,保留原结果。" } : !t.result && !t.error + ? { ...t, error: "已停止生成。可以修改问题,或重新生成。", stopped: true } : t, ), ); @@ -186,12 +234,18 @@ export default function AssetAssistant({ if (!message.trim() || abortRef.current) return; setShowGaps(false); setIndependent(false); - const id = ++nextId.current; + const inlineTurn = action && base?.resultId + ? turnsRef.current.find(turn => !!findConversationResult([turn], base.resultId!)) + : undefined; + const id = inlineTurn?.id ?? ++nextId.current; + autoFollow.current = inlineTurn ? null : id; + if (!inlineTurn) setViewedTurn(id); const controller = new AbortController(); abortRef.current = controller; - setDraft(""); + if (!inlineTurn) setDraft(""); setBusy(true); - setTurns((old) => [ + if (inlineTurn) setTurns(old => old.map(turn => turn.id === id ? { ...turn, updating: true, updateError: undefined } : turn)); + else setTurns((old) => [ ...old, { id, @@ -202,11 +256,7 @@ export default function AssetAssistant({ steps: ["正在理解你的问题"], }, ]); - requestAnimationFrame(() => - document - .getElementById(`aq-turn-${id}`) - ?.scrollIntoView({ block: "start", behavior: "smooth" }), - ); + requestAnimationFrame(() => { if (autoFollow.current === id) scrollToTurn(id); }); try { await askAssetAssistant( { @@ -219,19 +269,21 @@ export default function AssetAssistant({ (event) => { if (controller.signal.aborted) return; 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" }), - ); + if (inlineTurn) { + const container = scroller.current; + const anchor = (base?.resultId ? document.getElementById(`aq-query-${base.resultId}`)?.closest(".aq-result") : undefined) || document.getElementById(`aq-turn-${id}`); + const top = anchor?.getBoundingClientRect().top; + const revision = readerRevision.current; + requestAnimationFrame(() => { + if (container && anchor && top !== undefined && readerRevision.current === revision) { + container.scrollTop += anchor.getBoundingClientRect().top - top; + } + }); + } + if (autoFollow.current === id) + requestAnimationFrame(() => requestAnimationFrame(() => { + if (autoFollow.current === id) scrollToTurn(id, event.result.resultId); + })); sessionRef.current = event.result.sessionId; if (event.result.sessionId) sessions.current.set(event.result.sessionId, subject); @@ -244,9 +296,9 @@ export default function AssetAssistant({ : event.type === "step" ? { ...turn, steps: [...turn.steps, event.label] } : event.type === "error" - ? { ...turn, error: event.message } + ? inlineTurn ? { ...turn, updating: false, updateError: event.message } : { ...turn, error: event.message } : event.type === "result" - ? { ...turn, result: event.result } + ? { ...replaceAnalysisResult(turn, event.result, action ? base?.resultId : undefined), updating: false, updateError: undefined } : turn, ), ); @@ -257,7 +309,7 @@ export default function AssetAssistant({ setTurns((old) => old.map((t) => t.id === id - ? { + ? inlineTurn ? { ...t, updating: false, updateError: error instanceof Error ? error.message : "更新失败,请重试。" } : { ...t, expired: error instanceof AssistantRequestError && @@ -274,11 +326,13 @@ export default function AssetAssistant({ if (abortRef.current === controller) { abortRef.current = null; setBusy(false); - composer.current?.focus({ preventScroll: true }); + if (autoFollow.current === id) composer.current?.focus({ preventScroll: true }); } } }; const reset = () => { + autoFollow.current = null; + setViewedTurn(undefined); abortRef.current?.abort(); abortRef.current = null; setBusy(false); @@ -300,375 +354,432 @@ export default function AssetAssistant({ reset(); }, [subject]); return ( - { - if (!open) close(); - }} +
- { - e.preventDefault(); - composer.current?.focus(); - }} - > - 资产 AI 问数 - - 通过自然语言查询当前账号可见的资产数据,支持图表、表格和明细下钻。 - - -
-
-
- - AI 问数 - - 你的资产分析助手 + )} +
{ autoFollow.current = null; readerRevision.current++; }} + onTouchMove={() => { autoFollow.current = null; readerRevision.current++; }} + onPointerDown={() => { autoFollow.current = null; readerRevision.current++; }} + onKeyDown={(event) => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) { autoFollow.current = null; readerRevision.current++; } }}> + {showGaps ? ( + setShowGaps(false)} /> + ) : !turns.length ? ( +
+
+ +
+

关于资产,你想了解什么?

+

+ 用一句话,发现数据里的答案。 +
{" "} + 从全局统计,一路看到每一台车。 +

+
+ {PROMPTS.map((p) => ( + + ))} +
+
+ + 只读查询 · 真实数据 · 可追溯明细 +
-
- - - - - -
-
-
- - {subject || "全部所属公司"} - · - 当前账号可见范围 - - - 已连接业务数据 - -
- {showInfo && ( -
- 支持的分析 -

- 当前资产数量、运营率、库存分布、交退换车趋势与车辆明细。可按区域、城市、车型、型号和部门筛选或分组。 -

-

- 资产口径:当前台账;运营率 = 运营车辆 / - 筛选车辆。库存包含异动和待交车。可连续追问、切换分组、对比周期并查看明细。暂无数据的历史库存、成本或预测,会说明原因并记录需求。 -

-

- {capability?.message || - (capabilityError - ? "暂时无法读取服务状态,可以直接提问重试。" - : "正在检查分析服务连接状态。")} -

+ ) : ( +
+ {turns.map((turn, index) => { + const correction = turn.result?.resultId + ? correctedBy.get(turn.result.resultId) + : undefined; + const collapsed = + index < turns.length - 1 && + !!turn.result && + !expandedTurns.has(turn.id); + return ( +
+
+ 你 +

{turn.question}

+
+ {collapsed ? ( + + ) : ( + <> +
+ + + + 资产助手 + + {(turn.result?.engine ?? capability?.engine) === + "deepseek" + ? "DeepSeek" + : (turn.result?.engine ?? capability?.engine) === + "local" + ? "本地分析" + : "连接中"} + + {index < turns.length - 1 && turn.result && ( + + )} +
+ + {turn.updating &&

正在更新当前卡片…

} + {turn.updateError &&

{turn.updateError} 可再次点击分析操作重试。

} + {turn.error ? ( +
+ + {turn.stopped + ? "已停止生成" + : turn.expired + ? "会话已过期" + : "查询失败"} + +

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

+ +
+ ) : turn.result ? ( + void ask(text, base)} + operate={(action, label, base) => + void ask(label, base, action) + } + restore={(resultId) => { + const target = findConversationResult(turnsRef.current, resultId); + if (target) + void ask("返回此前分析", target, { + type: "restore", + }); + }} + active={ + activeResult?.resultId === turn.result.resultId + } + activeResultId={activeResult?.resultId} + correctionFor={(resultId) => { + const corrected = correctedBy.get(resultId)?.result; + return corrected ? { message: corrected.correction?.message, resultId: corrected.resultId } : undefined; + }} + activate={(base) => { + setActiveResult(base); + sessionRef.current = base.sessionId; + setIndependent(false); + }} + subject={subject} + disabled={busy || !!correction} + correctionMessage={ + correction?.result?.correction?.message + } + correctionResultId={correction?.result?.resultId} + showCorrection={(resultId) => { + const target = turnsRef.current.find( + (item) => item.result?.resultId === resultId, + ); + if (target?.result) { + setActiveResult(target.result); + sessionRef.current = target.result.sessionId; + setIndependent(false); + revealTurn(target.id); + } + }} + /> + ) : ( +
+ + + +
+ )} + + )} +
+ ); + })}
)} -
- {showGaps ? ( - setShowGaps(false)} /> - ) : !turns.length ? ( -
-
- -
-
- LESS SEARCHING. MORE UNDERSTANDING. -
-

关于资产,你想了解什么?

-

- 用一句话,发现数据里的答案。 -
{" "} - 从全局统计,一路看到每一台车。 -

-
- {PROMPTS.map((p) => ( - - ))} -
-
- - 只读查询 · 真实数据 · 可追溯明细 -
-
- ) : ( -
- {turns.map((turn, index) => { - const collapsed = - index < turns.length - 1 && - !!turn.result && - !expandedTurns.has(turn.id); - return ( -
-
- 你 -

{turn.question}

-
- {collapsed ? ( - - ) : ( - <> -
- - - - 资产助手 - - {(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 && ( -
- 正在分析 +
+
+ {turns.length > 0 && ( + + )} + {lastPlan && ( +
+ 正在分析 + + {lastPlan.kind === "flow" + ? "交退换车" + : CATEGORY_LABELS[lastPlan.category]} + + {GROUP_LABELS[lastPlan.groupBy]} + {lastPlan.start && ( - {lastPlan.kind === "flow" - ? "交退换车" - : CATEGORY_LABELS[lastPlan.category]} + {lastPlan.start} — {lastPlan.end} - {GROUP_LABELS[lastPlan.groupBy]} - {lastPlan.start && ( - - {lastPlan.start} — {lastPlan.end} - - )} - {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + )} + {Object.entries(lastPlan.filters).map(([dimension, values]) => ( + + ))} + {Object.entries(lastPlan.excludeFilters || {}).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} + ), + )} + {!!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 ? ( -
- )} - {independent && ( -

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

- )} -
{ - e.preventDefault(); - void ask(draft); - }} - > - -