From 9f89e9f61cc8905d2426491c791dca3820a092c1 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sat, 10 Oct 2026 21:43:03 +0800 Subject: [PATCH 1/8] fix(assets): show inventory operations managers and bump v1.1.19 --- package-lock.json | 4 ++-- package.json | 2 +- src/modules/assets/analysis-export.test.ts | 10 ++++++++++ src/modules/assets/analysis-export.ts | 4 ++-- .../assets/components/analysis/AssetAnalysisModal.tsx | 8 ++++---- src/modules/assets/types.ts | 1 + src/server/routes/vehicles.ts | 1 + src/server/routes/vehicles/model.test.ts | 1 + src/server/routes/vehicles/model.ts | 1 + src/server/routes/vehicles/repository.ts | 5 +++++ src/server/types.ts | 2 ++ 11 files changed, 30 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6de1da..cb536e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.18", + "version": "1.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.18", + "version": "1.1.19", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index 52ecf03..a21fffb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.18", + "version": "1.1.19", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/assets/analysis-export.test.ts b/src/modules/assets/analysis-export.test.ts index 26dde47..3da8e13 100644 --- a/src/modules/assets/analysis-export.test.ts +++ b/src/modules/assets/analysis-export.test.ts @@ -41,3 +41,13 @@ test('one Excel workbook contains statistics and all details in two named sheets const empty = await createAnalysisWorkbook([], []); assert.deepEqual(empty.SheetNames, ['统计表', '详情表']); }); + +test('inventory export uses assigned operations managers and a dash when unset', () => { + const vehicles = ['张三、李四', null, ' '].map(opsManagers => ({ + opsManagers, customerManager: '业务负责人', customerName: '客户', status: 'Inventory', + } as VehicleListItem)); + const rows = vehicleExportRows(vehicles, true); + assert.deepEqual(rows.map(row => row.运维负责人), ['张三、李四', '-', '-']); + assert.ok(rows.every(row => !('业务负责人' in row) && !('客户' in row))); + assert.equal(vehicleExportRows(vehicles)[0].业务负责人, '业务负责人'); +}); diff --git a/src/modules/assets/analysis-export.ts b/src/modules/assets/analysis-export.ts index 00f9c44..6748a27 100644 --- a/src/modules/assets/analysis-export.ts +++ b/src/modules/assets/analysis-export.ts @@ -12,7 +12,7 @@ export function statisticsExportRows(labels: string[], groups: { values: string[ })); } -export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] { +export function vehicleExportRows(vehicles: VehicleListItem[], inventory = false): ExportRow[] { const status: Record = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' }; const operation: Record = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' }; const hasPending = vehicles.some(v => v.pendingDelivery); @@ -21,7 +21,7 @@ export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] { 车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model, 省份: v.province, 城市: v.city || v.location, 状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus, - 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager, + ...(inventory ? { 运维负责人: v.opsManagers?.trim() || '-' } : { 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager }), 所属公司: v.subjectOrg, 合同: v.contractNo, ...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}), ...(hasMovement ? { diff --git a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx index c151142..1e7be3b 100644 --- a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx +++ b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx @@ -128,7 +128,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })), showDetails ? rows.length : analysis.total, ); - await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`); + await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows, view === 'inventory'), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`); }; return
@@ -164,7 +164,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi

车辆明细 {number(visibleVehicles.length)} 台

{detailBusy ?
正在加载车辆明细…
: detailError ?

{detailError}

: <> {vehicles.length !== detailGroup.count &&

车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。

} - {(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? : } + {(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? : } }
} @@ -175,14 +175,14 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
; } -function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) { +function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) { if (!vehicles.length) return
暂无匹配车辆
; const stateLabel = (vehicle: VehicleListItem) => { const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record)[vehicle.operationStatus || ''] || vehicle.operationStatus; const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record)[vehicle.status] || vehicle.status; return [business, operation].filter(Boolean).join(' · '); }; - return
{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => )}{vehicles.map(vehicle => )}
{label}
{vehicle.plateNumber || vehicle.vin || '未提供'}{vehicle.brandLabel || '—'} · {vehicle.type}{vehicle.model}{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}{stateLabel(vehicle)}{vehicle.customerName || '—'}{vehicle.orgName || '—'}{vehicle.departmentName || '—'}{vehicle.customerManager || '—'}{vehicle.subjectOrg || '—'}
; + return
{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].map(label => )}{vehicles.map(vehicle => {inventory ? : <>})}
{label}
{vehicle.plateNumber || vehicle.vin || '未提供'}{vehicle.brandLabel || '—'} · {vehicle.type}{vehicle.model}{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}{stateLabel(vehicle)}{vehicle.opsManagers?.trim() || '-'}{vehicle.customerName || '—'}{vehicle.orgName || '—'}{vehicle.departmentName || '—'}{vehicle.customerManager || '—'}{vehicle.subjectOrg || '—'}
; } type WeeklyDimension = 'date' | 'contract_type'; diff --git a/src/modules/assets/types.ts b/src/modules/assets/types.ts index 52c82b7..93e9812 100644 --- a/src/modules/assets/types.ts +++ b/src/modules/assets/types.ts @@ -104,6 +104,7 @@ export interface InventoryTypeSummary { } export interface VehicleListItem { + opsManagers?: string | null; color?: string; operationStatus?: string | null; inventoryKey?: string; diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index 3b847f7..7ed70dc 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -544,6 +544,7 @@ function vehicleListItem(v: Vehicle) { subjectOrg: v.subjectOrg, departmentName: v.departmentName, customerManager: v.customerManager, + opsManagers: v.opsManagers || null, brandLabel: v.brandLabel, orgName: v.orgName, }; diff --git a/src/server/routes/vehicles/model.test.ts b/src/server/routes/vehicles/model.test.ts index a16edfa..e47ad67 100644 --- a/src/server/routes/vehicles/model.test.ts +++ b/src/server/routes/vehicles/model.test.ts @@ -199,6 +199,7 @@ test('transforms database rows without changing the route model contract', () => subjectOrg: '主体', projectName: '项目', customerManager: '经理', + opsManagers: null, managerId: '9', brandLabel: '现代', }); diff --git a/src/server/routes/vehicles/model.ts b/src/server/routes/vehicles/model.ts index c1cc515..95ec822 100644 --- a/src/server/routes/vehicles/model.ts +++ b/src/server/routes/vehicles/model.ts @@ -255,6 +255,7 @@ export function transformRow(row: VehicleRow): Vehicle { subjectOrg: row.主体, projectName: row.项目名称, customerManager: row.客户经理, + opsManagers: row.ops_managers?.trim() || null, managerId: row.经理ID || null, brandLabel: row.车辆品牌Label, }; diff --git a/src/server/routes/vehicles/repository.ts b/src/server/routes/vehicles/repository.ts index b6d4dbb..c56260a 100644 --- a/src/server/routes/vehicles/repository.ts +++ b/src/server/routes/vehicles/repository.ts @@ -100,6 +100,11 @@ export const MAIN_SQL = `SELECT NULLIF(vi.registered_ownership, '') AS 主体, vor.project_name AS 项目名称, vor.business_manager AS 客户经理, + (SELECT GROUP_CONCAT( + COALESCE(NULLIF(TRIM(ops.nick_name), ''), NULLIF(TRIM(ops.user_name), ''), CAST(ops.user_id AS CHAR)) + ORDER BY ops.id SEPARATOR '、') + FROM vehicle_ops_manager_rel ops + WHERE ops.vehicle_id = vi.id AND ops.del_flag = '0') AS ops_managers, CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID FROM vehicle_info vi LEFT JOIN vehicle_status vs diff --git a/src/server/types.ts b/src/server/types.ts index 277a623..933858c 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -1,6 +1,7 @@ import type { AbnormalMoveDetail } from '../shared/assets/abnormal-move.js'; export interface VehicleRow { + ops_managers?: string | null; pending_delivery_id?: string | null; pending_contract_no?: string | null; pending_customer_name?: string | null; @@ -42,6 +43,7 @@ export interface VehicleRow { } export interface Vehicle { + opsManagers?: string | null; pendingDelivery?: { contractNo: string | null; customerName: string | null } | null; abnormalMove?: AbnormalMoveDetail | null; id: string; From 80f8d2d92ed46e8e34b52e8f9d46150c5c99c1cf Mon Sep 17 00:00:00 2001 From: shishengliang Date: Sat, 10 Oct 2026 21:55:06 +0800 Subject: [PATCH 2/8] fix(assets): emphasize daily flow details and sort newest first --- .../assets/components/FlowAnalysisModal.tsx | 9 ++++++--- .../components/overview/FlowStatisticsCard.tsx | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/modules/assets/components/FlowAnalysisModal.tsx b/src/modules/assets/components/FlowAnalysisModal.tsx index 6b9acff..4281de3 100644 --- a/src/modules/assets/components/FlowAnalysisModal.tsx +++ b/src/modules/assets/components/FlowAnalysisModal.tsx @@ -48,7 +48,8 @@ function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[] group.rows.push(row); groups.set(key, group); } - return Array.from(groups.values()).sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN')); + const dateIndex = dimensions.indexOf('date'); + return Array.from(groups.values()).sort((a, b) => (dateIndex >= 0 ? b.values[dateIndex].localeCompare(a.values[dateIndex]) : 0) || b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN')); } export function FlowAnalysisModal(props: FlowAnalysisModalProps) { @@ -68,7 +69,8 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo useDialogFocus(true, dialog, close); const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type) - && ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)), [selectedFlowDetails, selectedFlow]); + && ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)) + .sort((a, b) => b.date.localeCompare(a.date) || (b.eventTime || '').localeCompare(a.eventTime || '')), [selectedFlowDetails, selectedFlow]); const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => { const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other)))); const counts = new Map(); @@ -79,7 +81,8 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]); const groups = useMemo(() => { const flat = groupRows(filteredRows, dimensions); - return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length) : flat; + return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length, dimensions.includes('date') ? 'label' : 'count-desc', + (a, b, index) => dimensions[index] === 'date' ? b.values[index].localeCompare(a.values[index]) : a.values[index].localeCompare(b.values[index], 'zh-CN')) : flat; }, [filteredRows, dimensions, mergeActive]); const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]); const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS; diff --git a/src/modules/assets/components/overview/FlowStatisticsCard.tsx b/src/modules/assets/components/overview/FlowStatisticsCard.tsx index 181a85b..9f94d5b 100644 --- a/src/modules/assets/components/overview/FlowStatisticsCard.tsx +++ b/src/modules/assets/components/overview/FlowStatisticsCard.tsx @@ -1,4 +1,4 @@ -import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react'; +import { CalendarDays, ChevronDown, Download, ListFilter, Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import type { FlowType } from '../../api'; import { FLOW_META } from '../FlowDetailModal'; @@ -65,18 +65,24 @@ export function FlowStatisticsCard({ {flowDailyExpanded && ( Date: Tue, 13 Oct 2026 13:20:01 +0800 Subject: [PATCH 3/8] fix(mileage): skip missing daily mileage in interval totals --- src/modules/mileage/VehicleDetailModal.tsx | 29 ++-- src/modules/mileage/mileage-display.test.ts | 23 +++ src/modules/mileage/mileage-display.ts | 24 +++ .../components/FullscreenMonitoring.tsx | 5 +- .../monitoring/components/VehicleList.tsx | 9 +- src/modules/mileage/types.ts | 2 +- src/modules/mileage/xlsx-export.ts | 10 +- src/server/routes/mileage/cache.ts | 150 +----------------- src/server/routes/mileage/oneos-model.ts | 6 + src/server/routes/mileage/range-model.test.ts | 71 +++++++++ src/server/routes/mileage/range-model.ts | 148 +++++++++++++++++ src/server/routes/mileage/types.ts | 2 +- src/server/routes/mileage/vehicle-recent.ts | 5 +- 13 files changed, 309 insertions(+), 175 deletions(-) create mode 100644 src/modules/mileage/mileage-display.test.ts create mode 100644 src/modules/mileage/mileage-display.ts create mode 100644 src/server/routes/mileage/range-model.test.ts create mode 100644 src/server/routes/mileage/range-model.ts diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index 5ef2a0a..8326285 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -1,3 +1,4 @@ +import { hasDailyMileage, recentMileageStats } from './mileage-display'; import { useEffect, useMemo, useState } from 'react'; import { motion, AnimatePresence, useDragControls } from 'motion/react'; import { X, Truck } from 'lucide-react'; @@ -97,13 +98,10 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: // 排除"今日"列(数据未到位时易引起误读) const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]); - const stats = useMemo(() => { - const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0); - const synced = historyDays.filter(d => d.isDataSynced).length; - const avg = synced > 0 ? totalKm / synced : 0; - const max = Math.max(1, ...historyDays.map(d => d.dailyKm)); - return { totalKm, synced, avg, max, totalDays: historyDays.length }; - }, [historyDays]); + const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]); + const chartDays = useMemo(() => historyDays.map(day => ({ + ...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null, + })), [historyDays]); // 骨架天数:根据区间预估 const skeletonCount = useMemo(() => { @@ -206,14 +204,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
区间合计
{loading ? - : <>{Math.round(stats.totalKm).toLocaleString()}km} + : stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}km}
日均
{loading ? - : <>{Math.round(stats.avg).toLocaleString()}km} + : stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}km}
@@ -237,7 +235,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: ) : ( - + formatLabel(d, range)} @@ -249,7 +247,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: /> [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']} + filterNull={false} + formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']} labelFormatter={(d) => `日期 ${d}`} contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} @@ -306,15 +305,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- - {d.isDataSynced + + {hasDailyMileage(d) ? <>{Math.round(d.dailyKm).toLocaleString()} km - : 未对接} + : —}
diff --git a/src/modules/mileage/mileage-display.test.ts b/src/modules/mileage/mileage-display.test.ts new file mode 100644 index 0000000..13f6adf --- /dev/null +++ b/src/modules/mileage/mileage-display.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { monitoringMileageValue, recentMileageStats } from './mileage-display'; +import type { VehicleRecentDay } from './api'; +import type { MonitoringVehicle } from './types'; + +const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({ + date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null, +}); + +test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => { + assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), { + totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4, + }); + assert.equal(recentMileageStats([day(0, false)]).synced, 0); +}); + +test('列表全区间缺失时显示横杠,即使存在累计里程', () => { + const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle; + assert.equal(monitoringMileageValue(vehicle), '—'); + assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0); + assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417); +}); diff --git a/src/modules/mileage/mileage-display.ts b/src/modules/mileage/mileage-display.ts new file mode 100644 index 0000000..aa16014 --- /dev/null +++ b/src/modules/mileage/mileage-display.ts @@ -0,0 +1,24 @@ +import type { MonitoringVehicle } from './types'; +import type { VehicleRecentDay } from './api'; + +export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string { + if (vehicle.mileageAnomaly) return '数据异常'; + if (!vehicle.isDataSynced) return '—'; + return Math.max(0, vehicle.dailyKm || 0); +} + +export function hasDailyMileage(day: VehicleRecentDay): boolean { + return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0; +} + +export function recentMileageStats(days: VehicleRecentDay[]) { + const validDays = days.filter(hasDailyMileage); + const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0); + return { + totalKm, + synced: validDays.length, + avg: validDays.length ? totalKm / validDays.length : 0, + max: Math.max(1, ...validDays.map(day => day.dailyKm)), + totalDays: days.length, + }; +} diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index b183e93..71c9e85 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -1,3 +1,4 @@ +import { monitoringMileageValue } from '../../mileage-display'; import type { Dispatch, SetStateAction } from 'react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; @@ -283,6 +284,8 @@ export default function FullscreenMonitoring({ {fullscreenVehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); + const mileageValue = monitoringMileageValue(v); + const hasMileage = typeof mileageValue === 'number'; const statisticTime = vehicleStatisticTime(v, relativeNow); const sourceDisplay = vehicleSourceDisplay(v); return ( @@ -310,7 +313,7 @@ export default function FullscreenMonitoring({ {v.department || '-'} - {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接} + <>{mileageValue.toLocaleString()} {hasMileage ? 'km' : ''} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 38ab994..9477303 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -1,3 +1,4 @@ +import { monitoringMileageValue } from '../../mileage-display'; import type { RefObject } from 'react'; import { Truck } from 'lucide-react'; import { motion } from 'motion/react'; @@ -54,6 +55,8 @@ export default function VehicleList({
{vehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); + const mileageValue = monitoringMileageValue(v); + const hasMileage = typeof mileageValue === 'number'; const statisticTime = vehicleStatisticTime(v, relativeNow); const sourceDisplay = vehicleSourceDisplay(v); return ( @@ -102,8 +105,8 @@ export default function VehicleList({
- {v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} - {v.mileageAnomaly ? '' : 'km'} + {mileageValue.toLocaleString()} + {hasMileage ? 'km' : ''}
@@ -119,7 +122,7 @@ export default function VehicleList({ )} 区
- {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接} + <>{mileageValue.toLocaleString()} {hasMileage ? 'km' : ''}
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 258739f..c17f39c 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -7,7 +7,7 @@ export interface MonitoringVehicle { vin: string; dailyKm: number; mileageAnomaly?: string | null; - dailyMileage?: Record; + dailyMileage?: Record; totalKm: number | null; source: string; sourceProtocol: MileageSourceProtocol | null; diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index c77ffd7..43fc2da 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -1,3 +1,4 @@ +import { monitoringMileageValue } from './mileage-display'; import * as XLSX from 'xlsx'; import type { MonitoringVehicle } from './types'; @@ -27,8 +28,7 @@ function statusLabel(v: MonitoringVehicle): string { function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number { if (kind === 'today') { - if (!v.isDataSynced && v.totalKm == null) return '未对接'; - return Math.max(0, v.dailyKm || 0); + return monitoringMileageValue(v); } return v.totalKm != null ? v.totalKm : '未对接'; } @@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont v.rentStatus || '', v.region || '', ]; - const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0); + const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—'); return [ ...baseRow, ...dailyCols, @@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont v.project || '', v.rentStatus || '', v.region || '', - ...dayKeys.map(day => v.dailyMileage?.[day] || 0), - Math.max(0, v.dailyKm || 0), + ...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'), + mileageCell(v, 'today'), v.totalKm != null ? v.totalKm : '', ]), ]; diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index 2bf302a..0021f8f 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -4,11 +4,8 @@ import { dirname, join } from 'node:path'; import pool from '../../db.js'; import { fetchVehicleInfoMap } from './vehicle-info.js'; import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js'; -import { - sourceCategoryFromProtocol, - type MileageSourceCategory, - type OneOsProtocol, -} from './source-policy.js'; +import type { OneOsProtocol } from './source-policy.js'; +import { buildRangeMileage, type RangeMileageResult } from './range-model.js'; import { buildMonitoringFilters, buildPlateTargetNamesMap, @@ -18,8 +15,6 @@ import { mileageDatesBetween, previousMileageDate, toMileageRows, - type DailyMileageRow, - type MileageRow, type TargetRow, } from './cache-model.js'; import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js'; @@ -43,13 +38,6 @@ function shanghaiDate(): string { }).format(new Date()); } -export interface RangeMileageResult { - vehicles: CachedVehicle[]; - dailyTotals: { date: string; totalKm: number }[]; - start: string; - end: string; -} - async function fetchTargetRows(): Promise { return pool.execute( `SELECT t.id, t.target_name, v.plate_number @@ -131,23 +119,6 @@ export async function queryRangeMileage( endDate: string, protocolPriority?: OneOsProtocol[], ): Promise { - if (startDate === endDate) { - const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({ - ...vehicle, - dailyMileage: { [startDate]: vehicle.dailyKm }, - dailySourceProtocols: { [startDate]: vehicle.sourceProtocol }, - })); - return { - vehicles, - dailyTotals: [{ - date: startDate, - totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)), - }], - start: startDate, - end: endDate, - }; - } - const days = mileageDatesBetween(startDate, endDate); const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ fetchOneOsMileageDates(days, undefined, protocolPriority), @@ -156,122 +127,7 @@ export async function queryRangeMileage( fetchVehicleInfoMap(), fetchTargetRows(), ]); - const dailyRows: DailyMileageRow[] = []; - for (const [date, apiRows] of apiRowsByDate) { - for (const row of apiRows) { - dailyRows.push({ - plate: row.plateNumber, - vin: row.vin, - date, - daily_km: row.dailyMileageKm, - source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', - mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, - source_protocol: row.sourceProtocol, - data_time: row.dataTime, - calculated_at: row.calculatedAt, - updated_at: row.updatedAt, - }); - } - } - - const perVehicleDaily = new Map>(); - const perVehicleDailySources = new Map>(); - const perVehicleSourceCategories = new Map>(); - const perVehicleSum = new Map(); - const dailyTotals = new Map(); - const bestDailyRows = new Map(); - - for (const day of days) dailyTotals.set(day, 0); - - for (const row of dailyRows) { - const key = `${row.plate}\u0000${row.date}`; - const km = Math.max(0, Number(row.daily_km) || 0); - const existing = bestDailyRows.get(key); - if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) { - bestDailyRows.set(key, row); - } - } - - for (const row of bestDailyRows.values()) { - const km = Math.max(0, Number(row.daily_km) || 0); - const date = row.date; - const plate = row.plate; - dailyTotals.set(date, (dailyTotals.get(date) || 0) + km); - - const daily = perVehicleDaily.get(plate) || {}; - daily[date] = km; - perVehicleDaily.set(plate, daily); - const dailySources = perVehicleDailySources.get(plate) || {}; - dailySources[date] = row.source_protocol; - perVehicleDailySources.set(plate, dailySources); - const category = sourceCategoryFromProtocol(row.source_protocol); - if (category) { - const categories = perVehicleSourceCategories.get(plate) || new Set(); - categories.add(category); - perVehicleSourceCategories.set(plate, categories); - } - - const existing = perVehicleSum.get(plate); - perVehicleSum.set(plate, { - plate, - vin: existing?.vin || row.vin || '', - daily_km: String((Number(existing?.daily_km) || 0) + km), - total_km: null, - mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null, - source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'), - source_protocol: row.source_protocol || existing?.source_protocol || null, - data_time: row.data_time || existing?.data_time || null, - calculated_at: row.calculated_at || existing?.calculated_at || null, - updated_at: row.updated_at || existing?.updated_at || null, - }); - } - - const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row])); - for (const [plate, aggregate] of perVehicleSum) { - const endDateRow = endDateMap.get(plate); - if (!endDateRow) continue; - perVehicleSum.set(plate, { - ...aggregate, - vin: endDateRow.vin || aggregate.vin, - total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm), - source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol, - data_time: endDateRow.dataTime || aggregate.data_time, - updated_at: endDateRow.updatedAt || aggregate.updated_at, - }); - } - - const yesterdayMap = dailyMileageMap(yesterdayRows); - - const vehicles = mergeMonitoringVehicles( - Array.from(perVehicleSum.values()), - infoMap, - yesterdayMap, - buildPlateTargetNamesMap(targetRows), - regionMap, - ).map(vehicle => { - const dailyMileage = perVehicleDaily.get(vehicle.plate) || {}; - const dailySources = perVehicleDailySources.get(vehicle.plate) || {}; - const categories = perVehicleSourceCategories.get(vehicle.plate); - const completedDailyMileage: Record = {}; - const completedDailySources: Record = {}; - for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0; - for (const day of days) completedDailySources[day] = dailySources[day] || null; - return { - ...vehicle, - dailyMileage: completedDailyMileage, - dailySourceProtocols: completedDailySources, - sourceCategory: categories && categories.size > 1 - ? 'MIXED' as const - : categories?.values().next().value || vehicle.sourceCategory, - }; - }); - - return { - vehicles, - dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })), - start: startDate, - end: endDate, - }; + return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap); } export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { diff --git a/src/server/routes/mileage/oneos-model.ts b/src/server/routes/mileage/oneos-model.ts index 53a12f6..e92e3ed 100644 --- a/src/server/routes/mileage/oneos-model.ts +++ b/src/server/routes/mileage/oneos-model.ts @@ -14,6 +14,12 @@ export interface OneOsDailyMileage { sourceProtocol: OneOsProtocol | null; } +/** Missing or unusable daily mileage must not contribute to interval statistics. */ +export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null { + const km = row?.dailyMileageKm; + return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null; +} + const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export function assertMileageDate(date: string): void { diff --git a/src/server/routes/mileage/range-model.test.ts b/src/server/routes/mileage/range-model.test.ts new file mode 100644 index 0000000..0628081 --- /dev/null +++ b/src/server/routes/mileage/range-model.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildRangeMileage } from './range-model.js'; +import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js'; +import type { VehicleInfoRow } from './types.js'; + +const info: VehicleInfoRow = { + plate: '粤A06619F', vin: 'test', customer: null, department: null, manager: null, + manager_id: null, rent_status: null, entity: null, project: null, + inventory_location: null, brand_label: null, +}; +const row = (date: string, km: number | null, status: OneOsDailyMileage['status'] = 'NORMAL'): OneOsDailyMileage => ({ + plateNumber: info.plate, vin: 'test', date, dailyMileageKm: km, status, + totalMileageKm: 64413.6, dataTime: null, calculatedAt: null, updatedAt: null, + sourceProtocol: 'GB32960', +}); +function aggregate(rows: OneOsDailyMileage[], start = '2026-10-09', end = '2026-10-12') { + const byDate = new Map(); + for (const item of rows) byDate.set(item.date, [...(byDate.get(item.date) || []), item]); + return buildRangeMileage(start, end, byDate, rows.filter(item => item.date === end), [], new Map([[info.plate, info]]), [], {}); +} + +test('缺失或异常的一天不覆盖其余日期合计,明细保留空值', () => { + for (const status of ['NO_DATA', 'DATA_ANOMALY', 'NORMAL'] as const) { + const result = aggregate([ + row('2026-10-09', 1067), row('2026-10-10', null, status), + row('2026-10-11', 2212), row('2026-10-12', 2138), + ]); + const vehicle = result.vehicles[0]; + assert.equal(vehicle.dailyKm, 5417); + assert.equal(vehicle.mileageAnomaly, null); + assert.equal(vehicle.isDataSynced, true); + assert.equal(vehicle.totalKm, 64413.6); + assert.deepEqual(vehicle.dailyMileage, { + '2026-10-09': 1067, '2026-10-10': null, '2026-10-11': 2212, '2026-10-12': 2138, + }); + assert.equal(result.dailyTotals.reduce((sum, day) => sum + day.totalKm, 0), 5417); + } +}); + +test('整段无日里程保留缺失状态及累计里程,不能伪装为有效零里程', () => { + const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')]).vehicles[0]; + assert.equal(vehicle.dailyKm, 0); + assert.equal(vehicle.isDataSynced, false); + assert.equal(vehicle.mileageAnomaly, null); + assert.equal(vehicle.totalKm, 64413.6); + assert.deepEqual(Object.values(vehicle.dailyMileage!), [null, null, null, null]); +}); + +test('真实零里程是有效数据,缺失日期及重复无效记录不影响它', () => { + const vehicle = aggregate([row('2026-10-09', null, 'NO_DATA'), row('2026-10-09', 0)]).vehicles[0]; + assert.equal(vehicle.isDataSynced, true); + assert.equal(vehicle.dailyMileage?.['2026-10-09'], 0); + assert.equal(vehicle.dailyMileage?.['2026-10-10'], null); +}); + +test('单日区间同样跳过缺失数据', () => { + const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')], '2026-10-12').vehicles[0]; + assert.equal(vehicle.mileageAnomaly, null); + assert.equal(vehicle.isDataSynced, false); + assert.deepEqual(vehicle.dailyMileage, { '2026-10-12': null }); +}); + +test('区间汇总和单车明细共用有效里程口径', () => { + assert.equal(validDailyMileage(undefined), null); + assert.equal(validDailyMileage(row('2026-10-12', null)), null); + assert.equal(validDailyMileage(row('2026-10-12', 123, 'DATA_ANOMALY')), null); + assert.equal(validDailyMileage(row('2026-10-12', NaN)), null); + assert.equal(validDailyMileage(row('2026-10-12', 0)), 0); + assert.equal(validDailyMileage(row('2026-10-12', 12.3)), 12.3); +}); diff --git a/src/server/routes/mileage/range-model.ts b/src/server/routes/mileage/range-model.ts new file mode 100644 index 0000000..19e8e5e --- /dev/null +++ b/src/server/routes/mileage/range-model.ts @@ -0,0 +1,148 @@ +import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol } from './source-policy.js'; +import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js'; +import { + buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween, + type DailyMileageRow, type MileageRow, type TargetRow, +} from './cache-model.js'; +import type { CachedVehicle, VehicleInfoRow } from './types.js'; + +export interface RangeMileageResult { + vehicles: CachedVehicle[]; + dailyTotals: { date: string; totalKm: number }[]; + start: string; + end: string; +} + +export function buildRangeMileage( + startDate: string, + endDate: string, + apiRowsByDate: Map, + endDateRows: OneOsDailyMileage[], + yesterdayRows: OneOsDailyMileage[], + infoMap: Map, + targetRows: TargetRow[], + regionMap: Record, +): RangeMileageResult { + const days = mileageDatesBetween(startDate, endDate); + const dailyRows: DailyMileageRow[] = []; + for (const [date, apiRows] of apiRowsByDate) { + for (const row of apiRows) { + dailyRows.push({ + plate: row.plateNumber, + vin: row.vin, + date, + daily_km: validDailyMileage(row), + source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', + mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, + source_protocol: row.sourceProtocol, + data_time: row.dataTime, + calculated_at: row.calculatedAt, + updated_at: row.updatedAt, + }); + } + } + + const perVehicleDaily = new Map>(); + const perVehicleDailySources = new Map>(); + const perVehicleSourceCategories = new Map>(); + const perVehicleSum = new Map(); + const dailyTotals = new Map(); + const bestDailyRows = new Map(); + + for (const day of days) dailyTotals.set(day, 0); + + for (const row of dailyRows) { + if (row.daily_km == null) continue; + const key = `${row.plate}\u0000${row.date}`; + const km = Math.max(0, Number(row.daily_km) || 0); + const existing = bestDailyRows.get(key); + if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) { + bestDailyRows.set(key, row); + } + } + + for (const row of bestDailyRows.values()) { + const km = Math.max(0, Number(row.daily_km) || 0); + const date = row.date; + const plate = row.plate; + dailyTotals.set(date, (dailyTotals.get(date) || 0) + km); + + const daily = perVehicleDaily.get(plate) || {}; + daily[date] = km; + perVehicleDaily.set(plate, daily); + const dailySources = perVehicleDailySources.get(plate) || {}; + dailySources[date] = row.source_protocol; + perVehicleDailySources.set(plate, dailySources); + const category = sourceCategoryFromProtocol(row.source_protocol); + if (category) { + const categories = perVehicleSourceCategories.get(plate) || new Set(); + categories.add(category); + perVehicleSourceCategories.set(plate, categories); + } + + const existing = perVehicleSum.get(plate); + perVehicleSum.set(plate, { + plate, + vin: existing?.vin || row.vin || '', + daily_km: String((Number(existing?.daily_km) || 0) + km), + total_km: null, + mileage_anomaly: null, + source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'), + source_protocol: row.source_protocol || existing?.source_protocol || null, + data_time: row.data_time || existing?.data_time || null, + calculated_at: row.calculated_at || existing?.calculated_at || null, + updated_at: row.updated_at || existing?.updated_at || null, + }); + } + + const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row])); + // Preserve end-of-range odometer data even when every daily mileage is missing. + for (const [plate, endDateRow] of endDateMap) { + const aggregate = perVehicleSum.get(plate); + perVehicleSum.set(plate, { + plate, + daily_km: aggregate?.daily_km || '0', + source: aggregate?.source || 'NONE', + mileage_anomaly: null, + vin: endDateRow.vin || aggregate?.vin || '', + total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm), + source_protocol: endDateRow.sourceProtocol || aggregate?.source_protocol || null, + data_time: endDateRow.dataTime || aggregate?.data_time || null, + calculated_at: endDateRow.calculatedAt || aggregate?.calculated_at || null, + updated_at: endDateRow.updatedAt || aggregate?.updated_at || null, + }); + } + + const yesterdayMap = dailyMileageMap(yesterdayRows); + + const vehicles = mergeMonitoringVehicles( + Array.from(perVehicleSum.values()), + infoMap, + yesterdayMap, + buildPlateTargetNamesMap(targetRows), + regionMap, + ).map(vehicle => { + const dailyMileage = perVehicleDaily.get(vehicle.plate) || {}; + const dailySources = perVehicleDailySources.get(vehicle.plate) || {}; + const categories = perVehicleSourceCategories.get(vehicle.plate); + const completedDailyMileage: Record = {}; + const completedDailySources: Record = {}; + for (const day of days) completedDailyMileage[day] = dailyMileage[day] ?? null; + for (const day of days) completedDailySources[day] = dailySources[day] || null; + return { + ...vehicle, + dailyMileage: completedDailyMileage, + dailySourceProtocols: completedDailySources, + sourceCategory: categories && categories.size > 1 + ? 'MIXED' as const + : categories?.values().next().value || vehicle.sourceCategory, + }; + }); + + return { + vehicles, + dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })), + start: startDate, + end: endDate, + }; +} diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 4cb17dd..823a22d 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -4,7 +4,7 @@ export interface CachedVehicle { vin: string; dailyKm: number; mileageAnomaly?: string | null; - dailyMileage?: Record; + dailyMileage?: Record; totalKm: number | null; source: string; sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null; diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index 623867a..184b74f 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { validDailyMileage } from './oneos-model.js'; import { fetchOneOsMileageDates } from './oneos-api.js'; import { parseMileageSourcePriority, @@ -81,8 +82,8 @@ app.get('/:plate/recent', async (c) => { const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate); result.push({ date: key, - dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0, - isDataSynced: hit?.status === 'NORMAL', + dailyKm: validDailyMileage(hit) ?? 0, + isDataSynced: validDailyMileage(hit) != null, sourceProtocol: hit?.sourceProtocol || null, sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null), }); From 39ecde55d470b14648392a9049eb29fc8bdac564 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Tue, 13 Oct 2026 16:07:11 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(assets):=20=E5=AE=8C=E6=88=90=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E7=BB=B4=E4=BF=AE=E8=BF=90=E7=BB=B4=20BI=20=E4=B8=8E?= =?UTF-8?q?=20Excel=20=E6=8A=A5=E8=A1=A8=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/asset-fault-management.md | 81 + package-lock.json | 2068 ++++++++++++++++- package.json | 1 + src/app/modules.test.ts | 3 +- src/app/modules.ts | 3 + .../analysis/MobileAnalysisControls.tsx | 71 + .../analysis/MobileGroupedTable.tsx | 40 + .../components/analysis/mobile-analysis.css | 157 ++ .../overview/FlowDateRangePicker.tsx | 4 +- .../assets/faults/FaultAnalysisLoading.tsx | 16 + .../assets/faults/FaultAnalysisModal.tsx | 100 + src/modules/assets/faults/FaultManagement.tsx | 64 + src/modules/assets/faults/FaultTrendCard.tsx | 70 + .../assets/faults/FaultWeeklyReport.tsx | 65 + .../assets/faults/analysis-model.test.ts | 77 + src/modules/assets/faults/analysis-model.ts | 50 + .../assets/faults/fault-management.css | 221 ++ .../assets/faults/report-export.test.ts | 60 + src/modules/assets/faults/report-export.ts | 140 ++ src/modules/assets/useMobileLayout.ts | 12 + src/server/app.ts | 2 + src/server/routes/asset-faults/index.ts | 90 + src/server/routes/asset-faults/model.test.ts | 194 ++ src/server/routes/asset-faults/model.ts | 191 ++ src/server/routes/asset-faults/repository.ts | 67 + src/server/routes/asset-faults/trend.test.ts | 61 + src/server/routes/asset-faults/trend.ts | 42 + .../routes/asset-faults/weekly-report.test.ts | 52 + .../routes/asset-faults/weekly-report.ts | 31 + src/shared/assets/faults.ts | 65 + vite.config.ts | 2 + 31 files changed, 4096 insertions(+), 4 deletions(-) create mode 100644 docs/asset-fault-management.md create mode 100644 src/modules/assets/components/analysis/MobileAnalysisControls.tsx create mode 100644 src/modules/assets/components/analysis/MobileGroupedTable.tsx create mode 100644 src/modules/assets/components/analysis/mobile-analysis.css create mode 100644 src/modules/assets/faults/FaultAnalysisLoading.tsx create mode 100644 src/modules/assets/faults/FaultAnalysisModal.tsx create mode 100644 src/modules/assets/faults/FaultManagement.tsx create mode 100644 src/modules/assets/faults/FaultTrendCard.tsx create mode 100644 src/modules/assets/faults/FaultWeeklyReport.tsx create mode 100644 src/modules/assets/faults/analysis-model.test.ts create mode 100644 src/modules/assets/faults/analysis-model.ts create mode 100644 src/modules/assets/faults/fault-management.css create mode 100644 src/modules/assets/faults/report-export.test.ts create mode 100644 src/modules/assets/faults/report-export.ts create mode 100644 src/modules/assets/useMobileLayout.ts create mode 100644 src/server/routes/asset-faults/index.ts create mode 100644 src/server/routes/asset-faults/model.test.ts create mode 100644 src/server/routes/asset-faults/model.ts create mode 100644 src/server/routes/asset-faults/repository.ts create mode 100644 src/server/routes/asset-faults/trend.test.ts create mode 100644 src/server/routes/asset-faults/trend.ts create mode 100644 src/server/routes/asset-faults/weekly-report.test.ts create mode 100644 src/server/routes/asset-faults/weekly-report.ts create mode 100644 src/shared/assets/faults.ts diff --git a/docs/asset-fault-management.md b/docs/asset-fault-management.md new file mode 100644 index 0000000..ff921ad --- /dev/null +++ b/docs/asset-fault-management.md @@ -0,0 +1,81 @@ +# 故障管理 + +2026-10-13:故障管理为左侧独立业务入口 `/asset#faults`,与资产管理并列。首页展示四项总统计及故障趋势统计,不展示旧版维度排行或常驻故障明细。点击任一卡片进入多维分析,再进入故障明细。 + +## 四项指标 + +| 指标 | 口径 | +| --- | --- | +| 今日新增 | `fault_report_time` 在北京时间今日自然日内 | +| 本日解决 | 状态已归档且 `archived_at` 在北京时间今日内,与提报日无关 | +| 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 | +| 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 | + +「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“其中逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 + +- 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。 +- 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。 +- 逾期是当前在办的重叠子集,不与待处理、处理中相加。下钻明细不展示处置截止时间,也不提供该字段的统计维度;该字段仅保留用于逾期计算。 +- 四张卡片使用统一紧凑高度:桌面 164px、移动端 132px。 + +所有数量以故障主键去重、单位为“单”;同车多故障分别统计。有效范围为 `vehicle_fault_manage.del_flag='0' AND is_submit IN (0,1)`。保留退出运营、缺失车辆关联的历史故障;缺失日期不以创建时间补齐。 + +## 下钻 + +在资产管理九个维度(车型、型号、区域、城市、部门、业务负责人、客户、资产用途、运营状态)基础上,增加独立的「运维处置人」「车牌」「故障工单状态」,共十二个维度,「运维处置人」固定为第二个维度选项,「车牌」为第三个。车牌取故障台账车牌,同车多故障按故障单计数,缺失车牌归入「未关联车辆」。支持组合分组、多选筛选、合并单元格、组内小计、排序、筛选此组、故障明细搜索和分批加载。运维处置人与业务负责人独立,按人员 ID 分组,同名不同 ID 不合并;多人共同处置分别计入各人,总数、上级小计和明细按故障去重,人员行不可直接相加。 + +车辆相关维度使用车辆当前资料,运维处置人取故障或关联工单的当前处置分配。城市使用 OneOS `vehicle_info.operation_city` 对应的城市,不使用车机 GPS;区域采用 Asset BI 的宏观区域映射。客户/部门/业务负责人取每车最新 `vehicle_lease_order_record`,按 `last_delivery_time DESC,id DESC`,型号与车型复用资产管理的别名和分类函数。无匹配资料保留未知组。客户键采用散列,展示名称脱敏,不因相同脱敏名称合并不同客户。 + +明细第一列主显示车牌、辅显示故障单号,第二列为运维处置人;其后依次展示故障描述、状态、提报时间、归档时间、运营城市、客户 / 部门。处置人来源优先级:有效来源任务工单多人→任务工单单人→故障多人→故障单人或历史姓名;用户 BIGINT ID 保持字符串精度,停用/删除人员保留历史姓名。 + +## 权限与接口 + +- `GET /api/asset-faults/overview`:北京时间日期、数据时间、四项总数。 +- `GET /api/asset-faults/analysis?metric=reported|closed|open|waiting|processing|suspended|overdue|ledger`:所选指标的授权记录及分析单元,供前端在同份数据上分组和下钻。 +- 旧 `GET /api/asset-faults` 查询接口保留兼容,首页不再使用。 + +接口以只读一致性事务读取数据,不修改 OneOS 业务表,不运行定时任务或迁移。汇总及下钻先应用既有全量/部门/本人权限;历史故障不继承公共库存的权限放宽规则;缺车辆资料仅全量权限可见。无共享响应缓存,HTTP `no-store`。401 未认证、400 无效参数、503 查询失败。首页和下钻分别读取最新数据,实时变化可能造成短暂数量差异。 + +源码依据(`oneos/ln-asset-management`):`FaultLedgerStatus`、`FaultLedgerQuerySupport`、`FaultManualCreateSupport`、`FaultHandlerAssignmentService.resolveForSupervisorBatch`、`VehicleFaultDispositionServiceImpl.archiveNow`、`VehicleFaultDispositionMapper.xml`、`VehicleLeaseOrderRecordMapper.xml`。 + +## 验证 + +故障模型与路由测试覆盖北京时间午夜边界、跨日归档、全部日期在办、累计范围、权限隔离、客户脱敏、同车多故障、资产维度映射及总数与明细一致。同步运行导航、资产分组/筛选/合并单元格回归、TypeScript检查、生产构建及桌面/手机 Playwright 交互验证。仅本地开发,未部署。 + +## 故障趋势统计 + +复用交还车的日期选择器,默认北京时间近30天,支持自选日期(最多370天)、按日/按月切换。以 `fault_report_time` 划分新增批次,统计同一批故障截至查询时刻的已闭环(已归档)和未闭环(其他状态,含挂起)。不将该日归档的其他批次故障混入,归档时间可在提报区间之后。 + +堆叠柱总高度为新增数,新增 = 已闭环 + 未闭环。零新增周期补零;无提报时间不进入趋势;月聚合严格保持起止边界,不扩展整月。每日/每月统计表按新日期在前展示新增、已闭环、未闭环和闭环率(零分母显示横线)。汇总与周期数字均可下钻到故障多维分析及故障明细。点击柱图内已闭环 / 未闭环色块,按对应日或月和状态下钻;趋势下钻默认按「运维处置人 → 故障工单状态」分组,重置也恢复这两个维度,按月仍保留所选日期边界。 + +接口 `GET /api/asset-faults/trend?start=YYYY-MM-DD&end=YYYY-MM-DD&granularity=day|month` 返回区间汇总与周期序列。`GET /api/asset-faults/trend-analysis` 使用同样区间及粒度,可附 `period=YYYY-MM-DD|YYYY-MM` 和 `status=all|closed|unclosed`。两个接口均先应用原有数据权限,范围/粒度/周期校验在查询数据库前执行。总览和下钻按请求分别读取最新数据。 + +## 故障运维周报 + +周报复用故障处理趋势的日期选择器,默认业务周(周六至周五),支持自选最多370天。按最新要求仅展示「新增故障」「解决故障」两项区间合计、日期倒序的每日数量及表尾合计,均可下钻。汇总下方增加每日折线图,蓝色实线表示新增故障,绿色实线表示解决故障,与日期选择器、每日统计表使用同一份数据;补齐零值日期、纵轴从零开始,保留表格提供精确数量与下钻。 + +- 新增故障:按 `fault_report_time` 当日计单。 +- 解决故障:已归档故障按 `archived_at` 当日计单,不受提报日期限制。 + +已移除接收处理汇总、每日列、说明及故障明细中的接收时间列。现有接口暂保留接收指标兼容能力,页面不使用;不更改业务数据。 + +接口:`GET /api/asset-faults/weekly-report?start=YYYY-MM-DD&end=YYYY-MM-DD`;下钻 `GET /api/asset-faults/weekly-report-analysis` 传 `metric=reported|closed`,可附 `day=YYYY-MM-DD`。所有计数和下钻均先应用既有业务数据权限,沿用只读事务及 HTTP no-store。 + +页面时间标签统一为「解决时间」,底层仍取 `archived_at`,统计口径不变。周报折线图仍按时间正序,采用平滑实线、顶部图例、浅网格与统一悬浮提示。 + +故障工单状态独立于车辆运营状态:已归档为「已闭环」,其他状态(含待处理、处理中、挂起)均为「未闭环」,与故障处理趋势口径一致。所有故障下钻入口均可按该维度分组、多选筛选、查看明细。 + +所有下钻明细按提报时间倒序展示,无提报时间排在末尾;同一提报时间按故障 ID 倒序稳定排序。先筛选、排序,再分页,搜索和加载更多保持该顺序。 + +客户展示:已关联车辆且当前运营状态为库存、客户为空时,客户分组、筛选和明细显示「库存车」(独立键 `inventory-unlinked`);非库存或缺失车辆资料的空客户仍为「未关联客户」。有客户名称时仍按原客户展示和脱敏。此调整不改业务数据及权限。 + +下钻维度和筛选按入口保存在当前页面内存中;关闭、误点遮罩或 Esc 后重新打开恢复已应用设置。各入口互不覆盖,桌面与手机共用。点击重置恢复该入口默认维度并清空筛选;刷新整个页面后全部清除,不写入 localStorage 或跨刷新存储。每次重开仍重新请求实时数据。 + +## 报表导出 + +故障处理趋势和故障运维周报标题右侧均提供「导出 Excel」。导出当前已加载且经过权限过滤的统计快照;日期范围、趋势按日/按月粒度与页面一致,不重新查询或扩大数据范围。加载中及请求失败时禁用导出,生成文件期间显示进度状态并防止重复点击,失败可重试。 + +- 趋势:提报日期/月份、新增故障、已闭环、未闭环、闭环率,包含全部统计日期,按日期倒序。 +- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。 +- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。 +- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。 diff --git a/package-lock.json b/package-lock.json index cb536e2..7d04805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "cn": "^0.4.0", "dotenv": "^16.4.0", "echarts": "^6.1.0", + "exceljs": "4.4.0", "hono": "^4.7.0", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", @@ -747,6 +748,47 @@ "node": ">=18" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -3324,6 +3366,59 @@ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3336,6 +3431,73 @@ "node": ">=10" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -3345,6 +3507,32 @@ "node": ">= 6.0.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.10", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", @@ -3358,12 +3546,75 @@ "node": ">=6.0.0" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/bowser": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/bowser/-/bowser-1.9.4.tgz", "integrity": "sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3398,18 +3649,86 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", "integrity": "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==", "license": "MIT" }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3473,6 +3792,18 @@ "node": ">=0.8" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3580,6 +3911,41 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -3645,6 +4011,33 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3773,6 +4166,57 @@ "node": ">=12" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dateformat": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", @@ -3782,6 +4226,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3817,6 +4267,40 @@ "node": ">= 0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -3887,6 +4371,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3964,6 +4457,92 @@ "node": ">=10.13.0" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3994,6 +4573,41 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -4067,6 +4681,40 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/extend-shallow": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", @@ -4079,6 +4727,19 @@ "node": ">=0.10.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -4097,6 +4758,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/formstream": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/formstream/-/formstream-1.5.2.tgz", @@ -4145,6 +4821,18 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4159,6 +4847,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4168,6 +4872,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -4177,6 +4913,15 @@ "is-property": "^1.0.2" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4249,6 +4994,23 @@ "integrity": "sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==", "license": "MIT" }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -4261,6 +5023,43 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4277,9 +5076,20 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4290,6 +5100,33 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4302,6 +5139,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -4348,6 +5200,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -4358,12 +5236,37 @@ "url": "https://opencollective.com/immer" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -4373,12 +5276,139 @@ "node": ">=12" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-class-hotfix": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz", "integrity": "sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==", "license": "MIT" }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -4388,6 +5418,21 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4398,12 +5443,149 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-type-of": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/is-type-of/-/is-type-of-1.4.0.tgz", @@ -4415,6 +5597,64 @@ "isstream": "~0.1.2" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4516,6 +5756,18 @@ "integrity": "sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -4537,6 +5789,27 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4798,12 +6071,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4816,12 +6125,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -4840,12 +6168,30 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -4926,6 +6272,18 @@ "node": ">=4.0.0" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5074,6 +6432,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5095,6 +6462,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5135,6 +6531,39 @@ "node": ">=0.10.0" } }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pause-stream": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", @@ -5262,6 +6691,15 @@ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "license": "MIT" }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -5590,6 +7028,36 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/recharts": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", @@ -5635,6 +7103,48 @@ "redux": "^5.0.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5660,6 +7170,19 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -5715,6 +7238,31 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5735,6 +7283,45 @@ ], "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5750,6 +7337,18 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5775,6 +7374,58 @@ "semver": "bin/semver.js" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shell-quote": { "version": "1.8.3", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", @@ -5915,6 +7566,19 @@ "node": ">= 0.6" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-http": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.2.tgz", @@ -5967,6 +7631,63 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6017,6 +7738,36 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6067,12 +7818,40 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", "integrity": "sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==", "license": "MIT" }, + "node_modules/traverse": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.10.tgz", + "integrity": "sha512-pd6fq5qmiFuPA3TpdqBCqJXlOrSdCSt0l9IZDg83Vbf4x3m/X18prEwMMVM5LXp9vGL9NxjeiDGBrTasqyngXQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-to-primitive": "^1.3.4", + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.6", + "which-typed-array": "^1.1.24" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -6108,6 +7887,101 @@ "fsevents": "~2.3.3" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.6.tgz", + "integrity": "sha512-r+7YvGfM5TkTiDQ5EqWi/Z4L4KP3jUI+YCmTtWg1tmTHUw6xladLJqzR2heMaHBIM0TTFLZmn/EkIaEHYs/bIA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -6122,6 +7996,24 @@ "node": ">=14.17" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6140,6 +8032,24 @@ "node": ">=0.10.0" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6288,6 +8198,16 @@ "node": ">= 0.12.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -6869,6 +8789,97 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/win-release": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/win-release/-/win-release-1.1.1.tgz", @@ -6975,6 +8986,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -7030,6 +9047,55 @@ "node": ">=12" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zrender": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", diff --git a/package.json b/package.json index a21fffb..e8159c1 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "cn": "^0.4.0", "dotenv": "^16.4.0", "echarts": "^6.1.0", + "exceljs": "4.4.0", "hono": "^4.7.0", "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", diff --git a/src/app/modules.test.ts b/src/app/modules.test.ts index 02b8d40..5cbda3e 100644 --- a/src/app/modules.test.ts +++ b/src/app/modules.test.ts @@ -12,9 +12,10 @@ function heatmapChildIds(roles?: string[]): string[] { } test('keeps the base asset navigation and role-gated scheduling module', () => { - assert.deepEqual(moduleIds(), ['assets', 'mileage', 'heatmap']); + assert.deepEqual(moduleIds(), ['assets', 'faults', 'mileage', 'heatmap']); assert.deepEqual(moduleIds(['BI-SCHEDULE-OPT']), [ 'assets', + 'faults', 'mileage', 'heatmap', 'scheduling', diff --git a/src/app/modules.ts b/src/app/modules.ts index 143c9d3..c455097 100644 --- a/src/app/modules.ts +++ b/src/app/modules.ts @@ -7,12 +7,14 @@ import { Receipt, Route, Truck, + Wrench, } from 'lucide-react'; import type { ModuleConfig } from '../components/Shell'; import { canAccessEnergy, canAccessScheduling } from '../shared/auth/roles'; import type { PathSet } from './routing'; const AssetsModule = lazy(() => import('../modules/assets/AssetsModule')); +const FaultManagement = lazy(() => import('../modules/assets/faults/FaultManagement')); const MileageModule = lazy(() => import('../modules/mileage/MileageModule')); const SchedulingModule = lazy(() => import('../modules/scheduling/SchedulingModule')); const HydrogenModule = lazy(() => import('../modules/energy/HydrogenModule')); @@ -23,6 +25,7 @@ const HydrogenHeatmapModule = lazy(() => import('../modules/hydrogen-heatmap/Hyd const ASSET_MODULES: ModuleConfig[] = [ { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, + { id: 'faults', label: '故障管理', icon: Wrench, component: FaultManagement }, { id: 'mileage', label: '里程管理', icon: Route, component: MileageModule }, ]; diff --git a/src/modules/assets/components/analysis/MobileAnalysisControls.tsx b/src/modules/assets/components/analysis/MobileAnalysisControls.tsx new file mode 100644 index 0000000..80a888c --- /dev/null +++ b/src/modules/assets/components/analysis/MobileAnalysisControls.tsx @@ -0,0 +1,71 @@ +import { useId, useRef, useState } from 'react'; +import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react'; + +export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] }; +type Filters = Partial>; +type Props = { + available: MobileDimension[]; + dimensions: string[]; + filters: Filters; + defaults: string[]; + apply: (dimensions: string[], filters: Filters) => void; +}; + +/** Draft all mobile settings together; cancelling never changes the current result. */ +export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) { + const [mode, setMode] = useState<'group' | 'filter' | null>(null); + const [draftDimensions, setDraftDimensions] = useState(dimensions); + const [draftFilters, setDraftFilters] = useState(filters); + const [editing, setEditing] = useState(null); + const [query, setQuery] = useState(''); + const trigger = useRef(null); + const panel = useRef(null); + const id = useId(); + const selected = available.filter(d => filters[d.key]?.length); + const editor = available.find(d => d.key === editing); + const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || []; + const close = () => { setMode(null); trigger.current?.focus(); }; + const open = (next: 'group' | 'filter', button: HTMLButtonElement) => { + trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters }); + setEditing(null); setQuery(''); setMode(next); + requestAnimationFrame(() => panel.current?.focus()); + }; + const toggle = (key: string, value: string) => { + const values = draftFilters[key] || []; + setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] }); + }; + return
+
分析维度可多选 · 左右滑动
+
+
+ {available.map(d => )} + +
+ +
+ {!!selected.length &&
{selected.map(d => )}
} + {mode &&
{ if (e.target === e.currentTarget) close(); }}> + +
} +
; +} diff --git a/src/modules/assets/components/analysis/MobileGroupedTable.tsx b/src/modules/assets/components/analysis/MobileGroupedTable.tsx new file mode 100644 index 0000000..73195e0 --- /dev/null +++ b/src/modules/assets/components/analysis/MobileGroupedTable.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react'; +import { Filter } from 'lucide-react'; + +type Row = { key: string; label: string; values: ReactNode[]; count: number; share: number; note?: ReactNode }; +type Props = { + dimensions: string[]; + rows: Row[]; + unit: string; + merged: boolean; + setMerged: (value: boolean) => void; + spans: number[][]; + parentTotals: number[][]; + onDetails: (index: number) => void; + onFilter?: (index: number) => void; +}; + +/** Retain table semantics and actual parent row spans at narrow viewport widths. */ +export function MobileGroupedTable({ dimensions, rows, unit, merged, setMerged, spans, parentTotals, onDetails, onFilter }: Props) { + const multiple = dimensions.length > 1; + return
+
分组统计{multiple ? : 数量 · 占比}
+ {multiple &&

左右滑动查看完整表格{merged ? ' · 相同上级已合并' : ' · 每行独立显示'}

} +
+ + {dimensions.length ? dimensions.map((label, i) => ) : } + {rows.map((row, rowIndex) => 0 ? 'mobile-grid-group-start' : undefined}> + {dimensions.length ? row.values.map((value, colIndex) => { + const span = merged ? spans[rowIndex]?.[colIndex] ?? 1 : 1; + if (!span) return null; + const parent = merged && colIndex < dimensions.length - 1; + return ; + }) : } + + + + )} +
{label}范围数量({unit})占比操作
{value}{parent && 合计 {parentTotals[rowIndex]?.[colIndex]?.toLocaleString('zh-CN')} {unit}}当前结果{row.count.toLocaleString('zh-CN')}{row.note && {row.note}}{row.share.toFixed(1)}%
{onFilter && dimensions.length > 0 && }
+
+
; +} diff --git a/src/modules/assets/components/analysis/mobile-analysis.css b/src/modules/assets/components/analysis/mobile-analysis.css new file mode 100644 index 0000000..3afefb0 --- /dev/null +++ b/src/modules/assets/components/analysis/mobile-analysis.css @@ -0,0 +1,157 @@ +/* Shared mobile presentation for asset statistics, filters and vehicle details. */ +@media (max-width: 767px) { + .asset-analysis-overlay, .bi-flow-modal-backdrop { padding: 0; align-items: stretch; } + .asset-analysis-dialog, .bi-flow-modal { width: 100%; max-width: none; height: 100dvh; max-height: 100dvh; min-height: 0; border-radius: 0; font-size: 13px; } + .analysis-header, .bi-flow-modal-header { padding: max(6px, env(safe-area-inset-top)) 10px 6px; background: #fff; color: var(--color-slate-800, #1e293b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); } + .analysis-header { gap: 8px; } + .analysis-header > div { flex: 1; min-width: 0; } + .analysis-header h2, .bi-flow-modal-header h3 { font-size: 15px; } + .analysis-header h2 > svg { display: none; } + .analysis-header h2, .bi-flow-modal-header h3 { border-left: 3px solid var(--color-blue-600, #2563eb); padding-left: 8px; } + .analysis-header p, .bi-flow-modal-header p { font-size: 11px; line-height: 1.5; color: var(--color-slate-500, #64748b); } + .analysis-header button, .bi-flow-modal-close, .bi-flow-mobile-back { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; color: var(--color-slate-500, #64748b); background: var(--color-slate-50, #f8fafc); border-radius: 8px; } + .analysis-header button:hover, .bi-flow-modal-close:hover { background: var(--color-blue-50, #eff6ff); } + .analysis-scroll, .bi-flow-modal-body { padding: 12px 16px max(16px, env(safe-area-inset-bottom)); background: var(--app-bg, #f4f7fb); } + .analysis-result-bar { padding: 10px 14px; gap: 8px; font-size: 12px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-radius: 10px; color: var(--color-slate-600, #475569); margin-bottom: 10px; } + .analysis-result-bar strong { font-size: 28px; margin: 0 4px; color: var(--color-blue-600, #2563eb); } + .analysis-result-dimensions { display: none; } + .analysis-inventory-subsets { gap: 5px 12px; font-size: 11px; margin: -11px 0 8px; padding: 8px 14px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-top: 0; border-radius: 0 0 10px 10px; } + .analysis-inventory-subsets small { width: 100%; font-size: 11px; } + .analysis-mobile-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; } + .analysis-mobile-toolbar > button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid #d8e0eb; border-radius: 9px; padding: 8px 12px; background: #f8fafc; color: #334155; font-size: 13px; } + .analysis-mobile-toolbar > button:first-child { justify-content: space-between; } + .analysis-mobile-toolbar > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .analysis-mobile-toolbar svg { flex-shrink: 0; } + .analysis-mobile-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + .analysis-mobile-chips button { display: flex; align-items: center; gap: 6px; max-width: 100%; min-height: 44px; padding: 6px 10px; border-radius: 8px; color: var(--color-blue-700, #1d4ed8); background: #eff6ff; font-size: 11px; } + .analysis-mobile-chips span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .analysis-mobile-chips svg { flex-shrink: 0; } + .analysis-overview-tools > p, .analysis-overview-tools .analysis-merge-toggle { display: none; } + .analysis-overview-tools.is-empty { display: none; } + .analysis-overview-tools .analysis-table-options { width: 100%; justify-content: space-between; gap: 6px; } + .analysis-table-tools { margin: 8px 0; } + .analysis-table-tools label, .analysis-table-tools select, .analysis-export-action button { font-size: 11px; } + .analysis-table-tools select, .analysis-export-action button { min-height: 44px; } + .analysis-table-tools h3 { font-size: 14px; } + .analysis-table-tools h3 small { font-size: 12px; } + .analysis-table-total { border: 0; border-radius: 0; background: none; padding: 8px 0; font-size: 11px; line-height: 1.6; } + .analysis-table-total > span:last-child { display: none; } + .analysis-mobile-detail-scope { font-size: 12px; color: #64748b; margin-bottom: 8px; } + .analysis-search { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 8px; } + .analysis-search input { font-size: 16px; width: 100%; } + .analysis-vehicle-cards { display: grid; gap: 8px; margin-top: 10px; } + .analysis-vehicle-card { min-width: 0; padding: 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; } + .analysis-vehicle-card header { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 6px 12px; justify-content: space-between; } + .analysis-vehicle-card h4 { color: var(--color-blue-700, #1d4ed8); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; } + .analysis-vehicle-card header > span { font-size: 11px; padding: 3px 6px; border-radius: 4px; color: #475569; background: #f1f5f9; } + .analysis-vehicle-card > p { font-size: 12px; color: #64748b; line-height: 1.6; margin-top: 6px; overflow-wrap: anywhere; } + .analysis-vehicle-card summary { display: flex; align-items: center; min-height: 44px; font-size: 12px; color: var(--color-blue-600, #2563eb); cursor: pointer; } + .analysis-vehicle-card summary::after { content: '+'; margin-left: auto; } + .analysis-vehicle-card details[open] summary::after { content: '−'; } + .analysis-vehicle-card dl > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 10px; padding: 4px 0; font-size: 12px; line-height: 1.6; } + .analysis-vehicle-card dt { color: #64748b; } + .analysis-vehicle-card dd { color: #334155; overflow-wrap: anywhere; } + .analysis-footer { padding: 0 16px max(0px, env(safe-area-inset-bottom)); background: #fff; font-size: 11px; } + .analysis-footer details { width: 100%; max-height: 28dvh; overflow-y: auto; } + .analysis-footer summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; } + .analysis-footer details p { margin-bottom: 10px; line-height: 1.6; } + .analysis-message { font-size: 13px; min-height: 240px; } + .analysis-message h3 { font-size: 15px; } + .analysis-definition { font-size: 11px; } + .analysis-details .text-base { font-size: 14px; } + .analysis-details .text-sm { font-size: 13px; } + .analysis-details .text-xs { font-size: 11px; } + .analysis-details .space-y-3.px-3 { padding: 0; } + .analysis-details .space-y-3 article { box-shadow: none; } + .analysis-settings-backdrop { position: fixed; inset: 0; z-index: 30; background: rgb(15 23 42 / 38%); display: flex; align-items: flex-end; } + .analysis-settings-panel { display: flex; flex-direction: column; width: 100%; height: min(680px, 90dvh); min-height: 0; background: #fff; border-radius: 12px 12px 0 0; color: #334155; box-shadow: 0 -12px 36px #0f172a20; outline: none; } + .analysis-settings-panel header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; } + .analysis-settings-panel header h3 { flex: 1; font-size: 15px; font-weight: 600; } + .analysis-settings-panel header button { width: 44px; height: 44px; display: grid; place-items: center; } + .analysis-settings-search { display: flex; align-items: center; gap: 8px; margin: 12px 16px 0; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; } + .analysis-settings-search input { min-width: 0; width: 100%; font-size: 16px; outline: none; } + .analysis-settings-content { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 12px 16px; } + .analysis-settings-content > p { color: #64748b; font-size: 12px; line-height: 1.6; margin: 0 0 12px; } + .analysis-settings-groups { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 18px; } + .analysis-settings-groups button { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #d8e0eb; border-radius: 8px; font-size: 13px; } + .analysis-settings-groups button[aria-pressed=true] { color: var(--color-blue-700, #1d4ed8); background: #eff6ff; border-color: #93c5fd; } + .analysis-settings-groups .analysis-dimension-order { width: 20px; height: 20px; font-size: 11px; } + .analysis-settings-row { display: flex; gap: 10px; width: 100%; align-items: center; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 13px; } + .analysis-settings-row > span { flex: 1; } + .analysis-settings-row > small { font-size: 12px; color: #64748b; } + .analysis-settings-select { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; } + .analysis-settings-select button { min-height: 44px; color: var(--color-blue-600, #2563eb); } + .analysis-settings-select > span { margin-left: auto; color: #64748b; } + .analysis-settings-option { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; } + .analysis-settings-option input { width: 18px; height: 18px; accent-color: var(--color-blue-600, #2563eb); flex-shrink: 0; } + .analysis-settings-option span { flex: 1; overflow-wrap: anywhere; } + .analysis-settings-option small { color: #64748b; font-size: 12px; } + .analysis-settings-panel footer { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #e2e8f0; } + .analysis-settings-panel footer button { min-height: 46px; border-radius: 9px; background: #f1f5f9; font-size: 13px; } + .analysis-settings-panel footer .analysis-settings-apply { background: var(--color-blue-600, #2563eb); color: #fff; } +} +@media (max-width: 767px) { + .bi-flow-modal-header-row { align-items: center; gap: 8px; } + .bi-flow-modal-header-row > div { flex: 1; min-width: 0; } + .bi-flow-modal-summary { margin: 0 0 12px; background: var(--color-blue-50, #eff6ff); border-color: var(--color-blue-100, #dbeafe); } + .bi-flow-modal-summary > div { padding: 10px 8px; gap: 6px; } + .bi-flow-modal-summary span, .bi-flow-modal strong small { font-size: 11px; } + .bi-flow-modal-summary strong { font-size: 23px; } + .bi-flow-modal-section-heading { margin: 12px 0 4px; } + .bi-flow-modal-section-heading h4 { font-size: 14px; } + .bi-flow-modal-section-heading p { font-size: 11px; } + .bi-flow-modal-empty { font-size: 13px; line-height: 1.7; color: #64748b; } +} +@media (max-width: 767px) { + .analysis-details .space-y-3 dt { font-size: 11px; line-height: 1.8; } +} + +@media (max-width: 767px) { + .analysis-mobile-controls { position: sticky; top: -12px; z-index: 5; background: var(--app-bg, #f4f7fb); padding: 0 0 6px; } + .analysis-mobile-dimension-heading { display: flex; align-items: center; gap: 8px; color: var(--color-slate-700, #334155); font-size: 12px; } + .analysis-mobile-dimension-heading > span { color: var(--color-slate-500, #64748b); font-size: 11px; } + .analysis-mobile-dimension-heading > button { margin-left: auto; min-height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; } + .analysis-mobile-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } + .analysis-mobile-toolbar > button { align-self: start; height: 44px; padding: 0 8px; gap: 4px; white-space: nowrap; color: var(--color-blue-600, #2563eb); border-color: var(--color-blue-100, #dbeafe); background: var(--color-blue-50, #eff6ff); } + .analysis-dimension-strip { display: flex; gap: 6px; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; scrollbar-color: var(--color-slate-300, #cbd5e1) transparent; padding-bottom: 4px; } + .analysis-dimension-strip button { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 11px; border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 8px; background: #fff; color: var(--color-slate-600, #475569); font-size: 12px; } + .analysis-dimension-strip button[aria-pressed=true] { background: var(--color-blue-600, #2563eb); border-color: var(--color-blue-600, #2563eb); color: #fff; box-shadow: 0 2px 4px rgb(37 99 235 / 12%); } + .analysis-dimension-strip small { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; background: #ffffff2b; font-size: 10px; } + .analysis-mobile-grid { border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 2px 6px rgb(15 23 42 / 3%); } + .analysis-mobile-grid-heading { min-height: 44px; display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 12px; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-700, #334155); font-size: 12px; } + .analysis-mobile-grid-heading > span { font-size: 11px; color: var(--color-slate-500, #64748b); } + .analysis-mobile-grid-heading label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-size: 11px; cursor: pointer; } + .analysis-mobile-grid-heading input { appearance: none; width: 30px; height: 18px; border-radius: 10px; background: var(--color-slate-400, #94a3b8); position: relative; cursor: pointer; } + .analysis-mobile-grid-heading input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; } + .analysis-mobile-grid-heading input:checked { background: var(--color-blue-600, #2563eb); } + .analysis-mobile-grid-heading input:checked::after { left: 15px; } + .analysis-mobile-grid-hint { padding: 6px 12px; font-size: 11px; color: var(--color-slate-500, #64748b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); background: var(--color-slate-50, #f8fafc); } + .analysis-mobile-grid-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; } + .analysis-mobile-grid table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; } + .analysis-mobile-grid th { background: var(--color-slate-50, #f8fafc); color: var(--color-slate-500, #64748b); font-weight: 500; padding: 7px 6px; text-align: left; border-bottom: 1px solid var(--color-slate-200, #e2e8f0); } + .analysis-mobile-grid th:first-child { padding-left: 12px; } + .analysis-mobile-grid td { padding: 4px 6px; border-bottom: 1px solid var(--color-slate-100, #f1f5f9); vertical-align: middle; overflow-wrap: anywhere; color: var(--color-slate-700, #334155); line-height: 1.5; } + .analysis-mobile-grid tr:nth-child(even) td { background: var(--color-slate-50, #f8fafc); } + .analysis-mobile-grid tr:last-child td { border-bottom: 0; } + .analysis-mobile-grid td:first-child { padding-left: 12px; } + .analysis-mobile-grid .mobile-grid-parent { background: var(--color-blue-50, #eff6ff); border-right: 1px solid var(--color-blue-100, #dbeafe); font-weight: 600; vertical-align: top; padding-top: 14px; } + .analysis-mobile-grid .mobile-grid-parent small { display: block; margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--color-slate-500, #64748b); } + .analysis-mobile-grid .mobile-grid-group-start:not(:first-child) td { border-top: 1px solid var(--color-slate-300, #cbd5e1); } + .analysis-mobile-grid .mobile-grid-number { width: 60px; text-align: right; font-variant-numeric: tabular-nums; } + .analysis-mobile-grid th small { display: block; font-size: 10px; } + .analysis-mobile-grid td.mobile-grid-number strong { font-size: 17px; color: var(--color-blue-600, #2563eb); font-weight: 600; } + .analysis-mobile-grid td.mobile-grid-number small { display: block; font-size: 10px; color: var(--color-amber-600, #d97706); } + .analysis-mobile-grid .mobile-grid-share { width: 58px; text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; } + .mobile-grid-share > i { display: block; margin-top: 5px; height: 3px; border-radius: 3px; overflow: hidden; background: var(--color-blue-100, #dbeafe); } + .mobile-grid-share > i > i { display: block; height: 100%; background: var(--color-blue-500, #3b82f6); } + .analysis-mobile-grid .mobile-grid-actions { width: 90px; text-align: center; padding-left: 0; padding-right: 4px; } + .analysis-mobile-grid.without-filter .mobile-grid-actions { width: 56px; } + .mobile-grid-actions > div { display: flex; justify-content: flex-end; } + .mobile-grid-actions button { display: grid; place-items: center; min-width: 44px; height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; border-radius: 7px; } + .mobile-grid-actions button:active { background: #dbeafe; } + .mobile-grid-actions .mobile-grid-filter { color: var(--color-slate-400, #94a3b8); } + .analysis-table-tools { margin-top: 0; } + .analysis-mobile-detail-scope { padding: 10px 12px; border-left: 3px solid var(--color-blue-600, #2563eb); border-radius: 0 6px 6px 0; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-600, #475569); } + .analysis-vehicle-card { border-color: var(--color-slate-200, #e2e8f0); border-top: 3px solid var(--color-blue-100, #dbeafe); } + .bi-flow-modal-summary strong { color: var(--color-blue-600, #2563eb); } +} diff --git a/src/modules/assets/components/overview/FlowDateRangePicker.tsx b/src/modules/assets/components/overview/FlowDateRangePicker.tsx index ed33dee..5118728 100644 --- a/src/modules/assets/components/overview/FlowDateRangePicker.tsx +++ b/src/modules/assets/components/overview/FlowDateRangePicker.tsx @@ -8,7 +8,7 @@ const parseDate = (value: string) => new Date(`${value}T12:00:00`); const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1; -export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) { +export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) { const dialog = useRef(null); const [draft, setDraft] = useState(value); const [selecting, setSelecting] = useState<'start' | 'end'>('start'); @@ -69,7 +69,7 @@ export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onC className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500"> - 统计日期 · 运维签章时间 + 统计日期 · {dateLabel} {value.start.replaceAll('-', '/')}{value.end.replaceAll('-', '/')} {dayCount(value.start, value.end)} 天 diff --git a/src/modules/assets/faults/FaultAnalysisLoading.tsx b/src/modules/assets/faults/FaultAnalysisLoading.tsx new file mode 100644 index 0000000..20e6b4d --- /dev/null +++ b/src/modules/assets/faults/FaultAnalysisLoading.tsx @@ -0,0 +1,16 @@ +/** Neutral placeholders communicate structure without suggesting partial statistics. */ +export function FaultAnalysisLoading() { + return
+
+ + 正在加载故障数据… +
+ +
; +} diff --git a/src/modules/assets/faults/FaultAnalysisModal.tsx b/src/modules/assets/faults/FaultAnalysisModal.tsx new file mode 100644 index 0000000..83db596 --- /dev/null +++ b/src/modules/assets/faults/FaultAnalysisModal.tsx @@ -0,0 +1,100 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowLeft, Search, Wrench, X } from 'lucide-react'; +import { fetchJson } from '../../../auth/api-client'; +import { FAULT_OVERVIEW_METRICS, type FaultAnalysisResponse, type FaultOverviewMetric } from '../../../shared/assets/faults'; +import { getDimensionValueLabel } from '../analytics-model'; +import { FAULT_CLOSURE_LABELS, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS as DIMENSION_LABELS, analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, type FaultAnalysisDimension, type FaultAnalysisFilters, type FaultAnalysisGroup } from './analysis-model'; +import { dimensionRowSpans, orderDimensionGroups } from '../dimension-layout'; +import { FaultAnalysisLoading } from './FaultAnalysisLoading'; +import { DimensionPicker } from '../components/analysis/DimensionPicker'; +import { MobileAnalysisControls } from '../components/analysis/MobileAnalysisControls'; +import { MobileGroupedTable } from '../components/analysis/MobileGroupedTable'; +import { useDialogFocus } from '../components/useDialogFocus'; +import { useMobileLayout } from '../useMobileLayout'; +import '../components/analysis/asset-analysis.css'; +import '../components/analysis/mobile-analysis.css'; + +const available = FAULT_ANALYSIS_DIMENSIONS; +// Page-lifetime memory only: closing a modal retains settings; a full reload clears them. +const analysisSettings = new Map(); +export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[] } +export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) { + const ref = useRef(null); + useDialogFocus(true, ref, onClose); + const mobile = useMobileLayout(); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [revision, setRevision] = useState(0); + const defaultDimensions: FaultAnalysisDimension[] = source?.defaultDimensions || ['type']; + const settingsKey = source?.url || `overview:${metric}`; + const [dimensions, setDimensions] = useState(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions); + const [filters, setFilters] = useState(() => analysisSettings.get(settingsKey)?.filters || {}); + const [merged, setMerged] = useState(true); + const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc'); + const [group, setGroup] = useState(null); + const [search, setSearch] = useState(''); + const [limit, setLimit] = useState(50); + useEffect(() => { + const controller = new AbortController(); + setError(''); setData(null); + fetchJson(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal }) + .then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }); + return () => controller.abort(); + }, [metric, revision, source?.url]); + const records = useMemo(() => data?.rows || [], [data]); + const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]); + const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value); + const options = available.map(key => { + const otherFilters = { ...filters }; delete otherFilters[key]; + const counts = new Map(); + filterFaultRecords(records, otherFilters).forEach(row => faultDimensionValues(row, key).forEach(value => counts.set(value, (counts.get(value) || 0) + 1))); + // Keep applied selections removable even if another filter excludes them. + filters[key]?.forEach(value => { if (!counts.has(value)) counts.set(value, 0); }); + return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: label(key, value) })).sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')) }; + }); + const analysis = useMemo(() => analyzeFaultRecords(records, dimensions, filters), [records, dimensions, filters]); + const values = (row: FaultAnalysisGroup) => dimensions.map(d => row.values[d]!); + const ordered = orderDimensionGroups(analysis.rows, dimensions.length, values, row => row.count, sort, + (a, b, i) => label(dimensions[i], values(a)[i]).localeCompare(label(dimensions[i], values(b)[i]), 'zh-CN')); + const rows = ordered.slice(0, limit); + const spans = dimensionRowSpans(rows, dimensions.length, values); + const totals = faultParentTotals(ordered, dimensions); + const groupLabel = (row: FaultAnalysisGroup) => dimensions.map(d => label(d, row.values[d]!)).join(' · ') || '合计'; + const apply = (dims: FaultAnalysisDimension[], nextFilters: FaultAnalysisFilters) => { + const saved = { dimensions: [...dims], filters: Object.fromEntries(Object.entries(nextFilters).filter(([, values]) => values?.length).map(([key, values]) => [key, [...values!]])) }; + analysisSettings.set(settingsKey, saved); + setDimensions(saved.dimensions); setFilters(saved.filters); setLimit(50); + }; + const reset = () => { analysisSettings.delete(settingsKey); setDimensions([...defaultDimensions]); setFilters({}); setLimit(50); }; + const showDetails = (row: FaultAnalysisGroup) => { setGroup(row); setSearch(''); setLimit(50); }; + const filterGroup = (row: FaultAnalysisGroup) => apply(dimensions, { ...filters, ...Object.fromEntries(dimensions.map(d => [d, [row.values[d]!]])) }); + const groupIds = useMemo(() => new Set(group?.recordIds || []), [group]); + const details = group ? records.filter(row => groupIds.has(row.id) && + `${row.code} ${row.plate} ${row.description} ${row.status} ${row.handlers.map(h => h.label).join(' ')}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())) + .sort((a, b) => (b.reportedAt || '').localeCompare(a.reportedAt || '') || b.id.localeCompare(a.id, undefined, { numeric: true })) : []; + return
{ if (e.target === e.currentTarget) onClose(); }}> +
+

{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}

{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计

+
+ {error ?
{error}
: !data ? : group ? <> +
{groupLabel(group)} · {details.length.toLocaleString()} 单
+
{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => )}{details.slice(0, limit).map(row => )}
{h}
{row.plate || '未关联车辆'}{row.code || '未编号'}{row.handlers.map(h => h.label).join('、') || '未指派'}{row.description || '—'}{row.status}{row.reportedAt || '—'}{row.archivedAt || '—'}{row.city}{row.customerLabel}{row.department}
+ {!details.length &&

没有符合条件的故障明细

} + {details.length > limit && } + : <> + {mobile ? apply(dims as FaultAnalysisDimension[], next)} /> :

分析维度

点击维度分组,展开箭头可多选筛选
{options.map(d => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}
} +
当前结果{analysis.total.toLocaleString()}单{analysis.rows.length.toLocaleString()} 个分组{mobile && }
+ {dimensions.includes('handler') &&

运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。

} +
{!mobile &&

分组统计

}{!mobile && dimensions.length > 1 && }
+ {mobile ? DIMENSION_LABELS[d])} rows={rows.map(row => ({ ...row, label: groupLabel(row), values: dimensions.map(d => label(d, row.values[d]!)) }))} unit="单" merged={merged} setMerged={setMerged} spans={spans} parentTotals={totals} onDetails={i => showDetails(rows[i])} onFilter={i => filterGroup(rows[i])} /> :
{dimensions.length ? dimensions.map(d => ) : }{rows.map((row, ri) => {dimensions.length ? dimensions.map((d, ci) => { + const span = merged ? spans[ri][ci] : 1; + return span ? : null; + }) : })}
{DIMENSION_LABELS[d]}范围数量(单)占比操作
{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && 合计 {totals[ri][ci].toLocaleString()} 单}合计{row.count.toLocaleString()}{row.share.toFixed(1)}%{dimensions.length > 0 && }
} + {!rows.length &&

暂无符合条件的故障,可调整筛选条件。

} + {ordered.length > limit && } +

{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。

+ } +
+
+
; +} diff --git a/src/modules/assets/faults/FaultManagement.tsx b/src/modules/assets/faults/FaultManagement.tsx new file mode 100644 index 0000000..ddb472a --- /dev/null +++ b/src/modules/assets/faults/FaultManagement.tsx @@ -0,0 +1,64 @@ +import { Fragment, useCallback, useEffect, useState } from 'react'; +import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react'; +import { fetchJson } from '../../../auth/api-client'; +import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults'; +import { FaultAnalysisModal } from './FaultAnalysisModal'; +import { FaultWeeklyReport } from './FaultWeeklyReport'; +import { FaultTrendCard } from './FaultTrendCard'; +import './fault-management.css'; + +export const FAULT_DEFINITIONS = { + reported: '按今日提报时间统计', closed: '按今日解决时间统计', + open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账', +}; +const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList }; +const metrics = ['reported', 'closed', 'open', 'ledger'] as const; +export default function FaultManagement() { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const [metric, setMetric] = useState(null); + const close = useCallback(() => setMetric(null), []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(''); + fetchJson('/api/asset-faults/overview', { signal: controller.signal }) + .then(setData).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [revision]); + return
+

羚牛氢能-故障管理

{data ? `数据更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}
+ {error &&
{error}
} +
+

故障数据实时汇总

{data?.date || '—'} · 北京时间
+
{metrics.map(key => { + const Icon = icons[key]; + return
+ {key === 'open' && <> + +
+ {(['waiting', 'processing'] as const).map((part, index) => + {index > 0 && } + + )} +
+ } +
; + })}
+

当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

+
+ + + {metric && } +
; +} diff --git a/src/modules/assets/faults/FaultTrendCard.tsx b/src/modules/assets/faults/FaultTrendCard.tsx new file mode 100644 index 0000000..4160129 --- /dev/null +++ b/src/modules/assets/faults/FaultTrendCard.tsx @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react'; +import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { fetchJson } from '../../../auth/api-client'; +import type { FaultTrendResponse, FaultTrendStatus } from '../../../shared/assets/faults'; +import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton'; +import { downloadFaultReport } from './report-export'; +import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker'; +import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal'; + +function initialRange() { + const end = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10); + return { start: new Date(Date.parse(end) - 29 * 86400000).toISOString().slice(0, 10), end }; +} +const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' }; +const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const; +export function FaultTrendCard({ refresh }: { refresh: number }) { + const [range, setRange] = useState(initialRange); + const [granularity, setGranularity] = useState<'day' | 'month'>('day'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [revision, setRevision] = useState(0); + const [expanded, setExpanded] = useState(false); + const [source, setSource] = useState(null); + const chartRef = useRef(null); + const close = useCallback(() => setSource(null), []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(''); setData(null); + const params = new URLSearchParams({ ...range, granularity }); + fetchJson(`/api/asset-faults/trend?${params}`, { signal: controller.signal }) + .then(result => { if (!controller.signal.aborted) setData(result); }) + .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [range.start, range.end, granularity, revision, refresh]); + const drill = (status: FaultTrendStatus, period?: string) => { + if (!data || loading) return; + const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status }); + if (period) params.set('period', period); + setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'], + periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` }); + }; + return
+

故障处理趋势

data ? downloadFaultReport({ kind: 'trend', data }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
{(['day', 'month'] as const).map(value => )}
+

按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。

+ + {error ?
故障趋势加载失败,请稍后重试。
: loading || !data ?
正在加载故障趋势…
: <> +
{(['all', 'closed', 'unclosed'] as const).map(status => )}
+ {data.totals.reported ?
+ + + + data.granularity === 'day' ? v.slice(5) : v} minTickGap={24} axisLine={false} tickLine={false} /> + + { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} /> + + { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#10b981" stackId="faults" maxBarSize={34} isAnimationActive={false} /> + { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#f59e0b" stackId="faults" maxBarSize={34} isAnimationActive={false} /> + + +
:

所选时间范围暂无新增故障

} + + {expanded &&
{[...data.periods].reverse().map(row => {(['all', 'closed', 'unclosed'] as const).map(status => )})}
{data.granularity === 'day' ? '提报日期' : '提报月份'}新增已闭环未闭环闭环率
{row.period}{row.reported ? `${(row.closed / row.reported * 100).toFixed(1)}%` : '—'}
} +

已归档视为已闭环,其余状态(含挂起)为未闭环;无提报时间不纳入趋势。按月仍仅统计所选日期范围,不扩展至整月。状态截至 {new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。

+ } + {source && } +
; +} diff --git a/src/modules/assets/faults/FaultWeeklyReport.tsx b/src/modules/assets/faults/FaultWeeklyReport.tsx new file mode 100644 index 0000000..b9d1cb9 --- /dev/null +++ b/src/modules/assets/faults/FaultWeeklyReport.tsx @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ClipboardList, RefreshCw } from 'lucide-react'; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { fetchJson } from '../../../auth/api-client'; +import { FAULT_WEEKLY_METRICS, type FaultWeeklyMetric, type FaultWeeklyResponse } from '../../../shared/assets/faults'; +import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton'; +import { downloadFaultReport } from './report-export'; +import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker'; +import { getWeeklyFlowRange } from '../model'; +import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal'; + +const metrics: FaultWeeklyMetric[] = ['reported', 'closed']; +const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。'; +export function FaultWeeklyReport({ refresh }: { refresh: number }) { + const [range, setRange] = useState(() => getWeeklyFlowRange()); + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const [source, setSource] = useState(null); + const chartRef = useRef(null); + const close = useCallback(() => setSource(null), []); + const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setData(null); setError(''); + fetchJson(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end })}`, { signal: controller.signal }) + .then(result => { if (!controller.signal.aborted) setData(result); }) + .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [range.start, range.end, revision, refresh]); + const drill = (metric: FaultWeeklyMetric, day?: string) => { + if (!data || loading) return; + const params = new URLSearchParams({ start: data.start, end: data.end, metric }); + if (day) params.set('day', day); + setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`, + periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition }); + }; + return
+

故障运维周报

单位:单 data ? downloadFaultReport({ kind: 'weekly', data }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
+

按日展示新增故障和解决故障。默认业务周为周六至周五,可自选日期。

+ + {error ?
故障运维周报加载失败,请稍后重试。
: loading || !data ?
正在加载故障运维周报…
: <> +
{metrics.map(metric => )}
+
+
每日变化单位:单
新增故障解决故障
+ + + + v.slice(5)} minTickGap={32} tickMargin={12} padding={{ left: 12, right: 12 }} axisLine={false} tickLine={false} /> + + [`${value} 单`, name]} /> + + + + +
+
{metrics.map(metric => )}{[...data.days].reverse().map(row => {metrics.map(metric => )})}{metrics.map(metric => )}
日期{FAULT_WEEKLY_METRICS[metric]}
{row.date}{row.date === today && 今天}{new Date(`${row.date}T12:00:00+08:00`).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'Asia/Shanghai' })}
合计
+ {!metrics.some(metric => data.totals[metric]) &&

所选日期范围暂无新增或解决故障。

} +

新增故障按提报日期,解决故障按解决日期统计,不受提报日期限制。数据时间:{new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。

+ } + {source && } +
; +} diff --git a/src/modules/assets/faults/analysis-model.test.ts b/src/modules/assets/faults/analysis-model.test.ts new file mode 100644 index 0000000..08e9991 --- /dev/null +++ b/src/modules/assets/faults/analysis-model.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mapFault, faultHandlers } from '../../../server/routes/asset-faults/model.js'; +import type { FaultAnalysisRecord, FaultOption } from '../../../shared/assets/faults'; +import { analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS } from './analysis-model.js'; +function fault(id: string, handlers: FaultOption[], manager = '客户业务经理', city = '上海市'): FaultAnalysisRecord { + const row = mapFault({ id, business_manager: manager, city_name: city }, new Map()); + return { ...row, handlers, cell: row.assetCell }; +} +const a = { value: 'id:1234567890123456789', label: '处置甲' }; +const b = { value: 'id:2234567890123456789', label: '处置乙' }; +const data = [fault('shared', [a, b, a]), fault('single', [a]), fault('none', [])]; + +test('运维处置人是独立维度,未分配不以业务负责人兜底', () => { + assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('handler')); assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('manager')); + assert.equal(FAULT_ANALYSIS_LABELS.handler, '运维处置人'); + assert.deepEqual(faultDimensionValues(data[0], 'handler'), [a.value, b.value]); + assert.deepEqual(faultDimensionValues(data[0], 'manager'), ['客户业务经理']); + assert.deepEqual(faultDimensionValues(data[2], 'handler'), ['unassigned']); + assert.deepEqual(faultHandlers({ business_manager: '客户业务经理' }, new Map()), [{ value: 'unassigned', label: '未指派' }]); +}); +test('多人各自成组,总数和组内明细按故障ID去重,父级小计取并集', () => { + const result = analyzeFaultRecords([...data, data[0]], ['city', 'handler']); + assert.equal(result.total, 3); + assert.equal(result.rows.find(row => row.values.handler === a.value)?.count, 2); + assert.equal(result.rows.find(row => row.values.handler === b.value)?.count, 1); + assert.equal(result.rows.reduce((n, row) => n + row.count, 0), 4); + assert.ok(faultParentTotals(result.rows, ['city', 'handler']).every(row => row[0] === 3)); + for (const group of result.rows) assert.equal(new Set(group.recordIds).size, group.count); +}); +test('多选处置人取并集,不分组时不放大;筛选甲后不出现共享故障的乙分组', () => { + assert.equal(filterFaultRecords(data, { handler: [a.value, b.value] }).length, 2); + assert.equal(analyzeFaultRecords(data, ['city'], { handler: [a.value, b.value] }).rows[0].count, 2); + const result = analyzeFaultRecords(data, ['handler'], { handler: [a.value] }); + assert.equal(result.total, 2); assert.equal(result.rows.length, 1); assert.equal(result.rows[0].values.handler, a.value); + assert.equal(filterFaultRecords(data, { handler: [a.value], manager: ['其他业务经理'] }).length, 0); + assert.equal(filterFaultRecords(data, { handler: ['unassigned'] })[0].id, 'none'); +}); +test('同名处置人保持ID独立,组合筛选与组明细对应,空数据安全', () => { + const rows = [fault('one', [{ ...a, label: '同名' }]), fault('two', [{ ...b, label: '同名' }])]; + const result = analyzeFaultRecords(rows, ['manager', 'handler']); + assert.equal(result.rows.length, 2); + for (const group of result.rows) { + const matched = filterFaultRecords(rows, { manager: [group.values.manager!], handler: [group.values.handler!] }); + assert.deepEqual(matched.map(row => row.id), group.recordIds); + } + assert.deepEqual(analyzeFaultRecords([], ['handler']), { total: 0, rows: [] }); +}); + + +test('故障工单状态按归档闭环,与车辆运营状态独立;组合分组、筛选和去重口径一致', () => { + const states = ['已归档', '待处理', '处理中', '挂起', '未知状态']; + const rows = states.map((status, i) => ({ ...fault(String(i), i === 0 ? [a, b] : [a]), status })); + assert.deepEqual(rows.map(row => faultDimensionValues(row, 'faultStatus')), [['closed'], ['unclosed'], ['unclosed'], ['unclosed'], ['unclosed']]); + const result = analyzeFaultRecords(rows, ['handler', 'faultStatus']); + assert.equal(result.total, 5); + assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'closed')?.count, 1); + assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'unclosed')?.count, 4); + const parentTotals = faultParentTotals(result.rows, ['handler', 'faultStatus']); + result.rows.forEach((row, i) => assert.equal(parentTotals[i][0], row.values.handler === a.value ? 5 : 1)); + assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['closed'], handler: [a.value, b.value] }).map(row => row.id), ['0']); + assert.equal(filterFaultRecords(rows, { faultStatus: ['closed', 'unclosed'] }).length, 5); + assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['unclosed'], handler: [b.value] }), []); + for (const group of result.rows) assert.deepEqual(filterFaultRecords(rows, { handler: [group.values.handler!], faultStatus: [group.values.faultStatus!] }).map(row => row.id), group.recordIds); +}); + + +test('车牌位于第三个维度,同车多故障按单计数,支持组合筛选和缺失车牌', () => { + assert.equal(FAULT_ANALYSIS_DIMENSIONS[2], 'plate'); + const rows = [{ ...fault('1', [a]), plate: '沪A12345' }, { ...fault('2', [b]), plate: '沪A12345' }, { ...fault('3', [a]), plate: '粤A12345' }, { ...fault('4', []), plate: '' }]; + const grouped = analyzeFaultRecords(rows, ['plate']); + assert.equal(grouped.total, 4); + assert.equal(grouped.rows.find(row => row.values.plate === '沪A12345')?.count, 2); + assert.deepEqual(filterFaultRecords(rows, { plate: ['沪A12345'], handler: [b.value] }).map(row => row.id), ['2']); + assert.equal(filterFaultRecords(rows, { plate: ['沪A12345', '粤A12345'] }).length, 3); + assert.deepEqual(filterFaultRecords(rows, { plate: ['未关联车辆'] }).map(row => row.id), ['4']); +}); diff --git a/src/modules/assets/faults/analysis-model.ts b/src/modules/assets/faults/analysis-model.ts new file mode 100644 index 0000000..2a60299 --- /dev/null +++ b/src/modules/assets/faults/analysis-model.ts @@ -0,0 +1,50 @@ +import type { AssetAnalysisDimension } from '../../../shared/assets/analytics'; +import type { FaultAnalysisRecord } from '../../../shared/assets/faults'; +import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model'; + +export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus'; +export type FaultAnalysisFilters = Partial>; +export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]); +export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' }; +export const FAULT_CLOSURE_LABELS: Record = { closed: '已闭环', unclosed: '未闭环' }; +export interface FaultAnalysisGroup { + key: string; values: Partial>; + count: number; share: number; recordIds: string[]; +} +export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] { + if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆']; + if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed']; + return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]]; +} +export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] { + return [...new Map(rows.filter(row => FAULT_ANALYSIS_DIMENSIONS.every(d => !filters[d]?.length || faultDimensionValues(row, d).some(value => filters[d]!.includes(value)))).map(row => [row.id, row])).values()]; +} +/** Expand only the grouped handler dimension; totals and each group remain distinct fault IDs. */ +export function analyzeFaultRecords(rows: FaultAnalysisRecord[], dimensions: FaultAnalysisDimension[], filters: FaultAnalysisFilters = {}) { + const selected = filterFaultRecords(rows, filters); + const groups = new Map }>(); + const validDimensions = [...new Set(dimensions.filter(d => FAULT_ANALYSIS_DIMENSIONS.includes(d)))]; + for (const row of selected) { + let combinations: [FaultAnalysisDimension, string][][] = [[]]; + for (const d of validDimensions) { + const values = faultDimensionValues(row, d).filter(value => !filters[d]?.length || filters[d]!.includes(value)); + combinations = combinations.flatMap(parts => values.map(value => [...parts, [d, value] as [FaultAnalysisDimension, string]])); + } + for (const entries of combinations) { + const key = JSON.stringify(entries); + const group = groups.get(key) || { values: Object.fromEntries(entries), ids: new Set() }; + group.ids.add(row.id); groups.set(key, group); + } + } + return { total: selected.length, rows: [...groups].map(([key, group]): FaultAnalysisGroup => ({ key, values: group.values, + count: group.ids.size, share: selected.length ? group.ids.size / selected.length * 100 : 0, recordIds: [...group.ids] })) }; +} +/** Multi-handler child rows overlap: parent subtotals must use the union, never sum children. */ +export function faultParentTotals(rows: FaultAnalysisGroup[], dimensions: FaultAnalysisDimension[]): number[][] { + const totals = new Map>(); + const keys = rows.map(row => dimensions.slice(0, -1).map((_, depth) => JSON.stringify(dimensions.slice(0, depth + 1).map(d => row.values[d])))); + rows.forEach((row, i) => keys[i].forEach(key => { + const ids = totals.get(key) || new Set(); row.recordIds.forEach(id => ids.add(id)); totals.set(key, ids); + })); + return keys.map(row => row.map(key => totals.get(key)!.size)); +} diff --git a/src/modules/assets/faults/fault-management.css b/src/modules/assets/faults/fault-management.css new file mode 100644 index 0000000..870e235 --- /dev/null +++ b/src/modules/assets/faults/fault-management.css @@ -0,0 +1,221 @@ +.fault-page { color:#334155; min-width:0; padding:24px; } +.fault-page-header { position:relative; z-index:30; margin:-24px -24px 24px; padding:18px 24px 12px; background:rgb(255 255 255 / .95); border-bottom:1px solid #f1f5f9; text-align:center; } +.fault-page-header h1 { font-size:16px; font-weight:600; letter-spacing:.04em; color:#1f2937; } +.fault-page-header>div { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px; margin-top:9px; font-size:10px; color:#94a3b8; } +.fault-page-header button { display:inline-flex; align-items:center; gap:4px; color:#64748b; cursor:pointer; padding:4px; } +.fault-summary { background:white; border:1px solid #f1f5f9; border-radius:16px; padding:20px; box-shadow:0 1px 3px rgb(15 23 42 / .02); } +.fault-summary-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; } +.fault-summary-heading h2 { font-size:14px; font-weight:600; color:#1e293b; } +.fault-summary-heading>span { color:#94a3b8; font-size:11px; } +.fault-metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; align-items:start; } +.fault-metric { text-align:left; padding:0; border:1px solid #e2e8f0; background:#fff; border-radius:12px; transition:border-color .15s,box-shadow .15s; min-width:0; } +.fault-metric:hover:not(:disabled) { border-color:#93c5fd; box-shadow:0 4px 12px rgb(37 99 235 / .06); } +.fault-metric-main:focus-visible { outline:2px solid #2563eb; outline-offset:3px; } +.fault-metric-main:disabled { cursor:default; opacity:.6; } +.fault-metric-label { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:500; } +.fault-metric-label>svg { margin-left:auto; color:#94a3b8; } +.fault-metric-icon { display:flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:9px; background:#eff6ff; color:#2563eb; flex-shrink:0; } +.fault-metric-closed .fault-metric-icon { background:#ecfdf5; color:#059669; } +.fault-metric-open .fault-metric-icon { background:#fff7ed; color:#d97706; } +.fault-metric-ledger .fault-metric-icon { background:#f5f3ff; color:#7c3aed; } +.fault-metric-count { display:flex; align-items:baseline; gap:8px; margin:18px 0 10px; } +.fault-metric-count strong { font-size:32px; line-height:1.15; font-weight:650; color:#0f172a; font-variant-numeric:tabular-nums; } +.fault-metric-count>span { font-size:11px; color:#94a3b8; } +.fault-metric p { font-size:11px; color:#94a3b8; line-height:1.6; min-height:35px; } +.fault-metric-link { display:block; font-size:11px; color:#3b82f6; margin-top:12px; } +.fault-message { margin:12px 0; padding:14px; border:1px solid #fecaca; background:#fef2f2; border-radius:8px; color:#b91c1c; font-size:12px; } +.fault-message button { margin-left:12px; text-decoration:underline; cursor:pointer; } +.fault-parent-total,.fault-detail-table small { display:block; margin-top:5px; font-size:11px; color:#94a3b8; } +.fault-detail-table table { min-width:1100px; } +.fault-detail-table td:first-child { white-space:nowrap; } +.fault-detail-table td:first-child strong { color:#1e293b; font-weight:600; } +.fault-detail-table td:nth-child(2) { min-width:100px; max-width:180px; white-space:normal; overflow-wrap:anywhere; } +.fault-detail-table td:nth-child(3) { min-width:180px; max-width:320px; white-space:normal; overflow-wrap:anywhere; } +.fault-detail-search { display:flex; gap:6px; border:1px solid #e2e8f0; padding:8px; border-radius:6px; } +.fault-detail-search input { min-width:0; width:220px; outline:none; } +.fault-empty { text-align:center; padding:32px; color:#94a3b8; } +.fault-analysis-note { margin-top:16px; color:#94a3b8; font-size:11px; line-height:1.7; } +.fault-analysis-dialog .analysis-dimension-options { align-items:flex-start; } +.fault-more { display:flex; margin:16px auto; } +@media(max-width:1100px) { .fault-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); } } +@media(max-width:767px) { + .fault-page { padding:12px; } + .fault-page-header { position:relative; z-index:30; margin:-12px -12px 16px; padding:14px 12px 10px; } + .fault-page-header h1 { font-size:15px; } + .fault-summary { padding:14px; border-radius:12px; } + .fault-summary-heading { align-items:flex-start; flex-direction:column; gap:6px; margin-bottom:14px; } + .fault-metrics { gap:10px; } + .fault-metric { padding:0; border-radius:10px; } + .fault-metric-label { font-size:12px; gap:6px; } + .fault-metric-icon { width:28px; height:28px; } + .fault-metric-label>svg { display:none; } + .fault-metric-count strong { font-size:29px; } + .fault-metric p { font-size:10px; } + .fault-detail-search { width:100%; } + .fault-detail-search input { width:100%; } +} + +.fault-metric-main { display:block; width:100%; padding:18px; text-align:left; cursor:pointer; border-radius:12px; } +/* Fixed compact height keeps every metric aligned, including the open-work breakdown. */ +.fault-metric { height:164px; display:flex; flex-direction:column; } +.fault-metric-main { flex:1; min-height:0; display:flex; flex-direction:column; padding:12px 14px; } +.fault-metric-label { width:100%; } +.fault-metric-icon { width:28px; height:28px; border-radius:7px; } +.fault-metric-count { margin:8px 0 5px; } +.fault-metric-count strong { font-size:28px; } +.fault-metric p { min-height:0; font-size:10px; } +.fault-metric-link { margin-top:auto; padding-top:6px; font-size:10px; } +.fault-metric-open { position:relative; } +.fault-metric-open .fault-metric-main>p,.fault-metric-open .fault-metric-link { display:none; } +.fault-open-breakdown { display:flex; align-items:center; gap:4px; margin:0 12px 12px; padding-top:8px; border-top:1px solid #f1f5f9; } +.fault-submetric { flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:4px 2px; border-radius:5px; font-size:10px; cursor:pointer; color:#64748b; } +.fault-submetric:hover { background:#eff6ff; color:#2563eb; } +.fault-submetric:focus-visible { outline:2px solid #2563eb; outline-offset:1px; } +.fault-submetric:disabled { opacity:.6; cursor:default; } +.fault-submetric strong { font-variant-numeric:tabular-nums; font-size:16px; line-height:1.2; color:#334155; } +.fault-submetric svg { display:none; } +.fault-composition-plus { color:#94a3b8; font-size:18px; font-weight:400; } +.fault-submetric-waiting { background:#fffbeb; color:#b45309; } +.fault-submetric-processing { background:#eff6ff; color:#2563eb; } +.fault-overdue-subset { position:absolute; right:14px; top:51px; display:flex; align-items:baseline; gap:4px; padding:5px 8px; border:1px solid #fee2e2; border-radius:6px; background:#fef2f2; color:#dc2626; cursor:pointer; } +.fault-overdue-subset span,.fault-overdue-subset small { font-size:10px; } +.fault-overdue-subset strong { font-size:16px; line-height:1.2; font-variant-numeric:tabular-nums; } +.fault-overdue-subset:hover { background:#fee2e2; } +.fault-overdue-subset:focus-visible { outline:2px solid #dc2626; outline-offset:2px; } +.fault-overdue-subset:disabled { opacity:.6; cursor:default; } +.fault-summary-note { margin-top:12px; font-size:10px; color:#94a3b8; line-height:1.6; } +@media(max-width:767px) { + .fault-metric { height:172px; } + .fault-metric-main { padding:12px; } + .fault-open-breakdown { margin:0 8px 10px; gap:2px; } + .fault-submetric { min-height:40px; } + .fault-overdue-subset { right:8px; top:53px; gap:3px; padding:5px; } + .fault-overdue-subset span,.fault-overdue-subset small { font-size:9px; } +} +.fault-trend { margin-top:20px; } +.fault-trend-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; } +.fault-trend-heading h2 { display:flex; align-items:center; gap:7px; font-size:14px; font-weight:600; } +.fault-trend-heading h2 svg { color:#2563eb; } +.fault-trend-heading [role=group] { display:flex; padding:3px; border-radius:8px; background:#f1f5f9; } +.fault-trend-heading button { padding:6px 12px; border-radius:6px; font-size:11px; color:#64748b; cursor:pointer; } +.fault-trend-heading button[aria-pressed=true] { color:#2563eb; background:#fff; box-shadow:0 1px 3px #e2e8f0; } +.fault-trend-definition { margin-top:7px; font-size:11px; line-height:1.7; color:#94a3b8; } +.fault-trend-totals { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:14px 0; padding:10px; border:1px solid #f1f5f9; border-radius:10px; background:#f8fafc; } +.fault-trend-totals button { display:flex; flex-direction:column; align-items:center; gap:4px; cursor:pointer; border-right:1px solid #e2e8f0; padding:4px; } +.fault-trend-totals button:last-child { border-right:0; } +.fault-trend-totals strong { font-size:21px; line-height:1.2; font-weight:650; font-variant-numeric:tabular-nums; } +.fault-trend-totals span { font-size:11px; } +.fault-trend-all { color:#2563eb; }.fault-trend-closed { color:#059669; }.fault-trend-unclosed { color:#d97706; } +.fault-trend button:disabled { cursor:default; opacity:.5; } +.fault-trend button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; } +.fault-trend-chart { width:100%; min-width:0; height:240px; } +.fault-trend-loading { min-height:120px; display:flex; align-items:center; justify-content:center; gap:8px; color:#94a3b8; font-size:12px; } +.fault-trend-expand { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; margin-top:12px; padding:12px; border:1px solid #bfdbfe; border-radius:8px; color:#2563eb; background:#eff6ff; font-size:12px; cursor:pointer; text-align:left; } +.fault-trend-table { max-height:400px; overflow:auto; margin-top:12px; border:1px solid #e2e8f0; border-radius:8px; } +.fault-trend-table table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; } +.fault-trend-table th { text-align:right; padding:10px 12px; position:sticky; top:0; background:#f8fafc; color:#64748b; z-index:1; } +.fault-trend-table td { padding:5px 12px; text-align:right; border-top:1px solid #f1f5f9; font-variant-numeric:tabular-nums; } +.fault-trend-table th:first-child,.fault-trend-table td:first-child { text-align:left; } +.fault-trend-table button { min-height:32px; min-width:32px; color:#2563eb; cursor:pointer; } +@media(max-width:767px) { .fault-trend { margin-top:14px; }.fault-trend-heading h2 { font-size:12px; }.fault-trend-heading button { padding:7px 10px; }.fault-trend-table th,.fault-trend-table td { padding-left:6px; padding-right:6px; font-size:10px; }.fault-trend-table button { min-height:40px; }.fault-trend-expand { font-size:11px; } } +.fault-weekly .fault-trend-totals { grid-template-columns:repeat(2,minmax(0,1fr)); } +.fault-weekly-unit { font-size:11px; color:#94a3b8; } +.fault-weekly-reported,.fault-weekly-table button.fault-weekly-reported { color:#2563eb; } +.fault-weekly-received,.fault-weekly-table button.fault-weekly-received { color:#d97706; } +.fault-weekly-closed,.fault-weekly-table button.fault-weekly-closed { color:#059669; } +.fault-weekly-table td small { display:inline-block; margin-left:8px; color:#94a3b8; font-size:10px; } +.fault-weekly-table tfoot th { position:static; border-top:1px solid #e2e8f0; background:#f8fafc; padding-top:4px; padding-bottom:4px; } +.fault-weekly-empty { margin-top:10px; font-size:12px; color:#94a3b8; text-align:center; } +@media(max-width:767px) { .fault-weekly-table td small { display:block; margin:0; }.fault-weekly .fault-trend-totals span { font-size:10px; } } + +.fault-weekly-chart { height:auto; padding:16px 12px 4px; border:1px solid #edf2f7; border-radius:12px; background:#fff; } +.fault-chart-heading { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding:0 4px 4px; font-size:12px; color:#334155; font-weight:600; } +.fault-chart-heading small { margin-left:10px; font-size:10px; color:#94a3b8; font-weight:400; } +.fault-chart-legend { display:flex; align-items:center; gap:18px; font-size:11px; color:#64748b; font-weight:400; } +.fault-chart-legend>span { display:flex; align-items:center; gap:7px; } +.fault-chart-legend i { position:relative; width:20px; height:2px; background:#2563eb; } +.fault-chart-legend i::after { content:''; position:absolute; left:7px; top:-3px; width:8px; height:8px; border:2px solid #2563eb; border-radius:50%; background:#fff; } +.fault-chart-key-closed i { background:#059669; } +.fault-chart-key-closed i::after { border-color:#059669; background:#059669; } +@media(max-width:767px) { .fault-weekly-chart { padding:14px 6px 2px; }.fault-chart-heading { gap:10px; }.fault-chart-legend { gap:12px; font-size:10px; } } + +/* Compact mobile summary: preserve equal card heights and all drill-down targets. */ +@media(max-width:767px) { + .fault-page-header { margin-bottom:10px; padding:10px 12px 6px; } + .fault-page-header>div { gap:8px; margin-top:3px; } + .fault-page-header button { min-height:32px; } + .fault-page>.fault-summary:first-of-type { padding:12px; } + .fault-summary-heading { flex-direction:row; flex-wrap:wrap; align-items:center; gap:4px 8px; margin-bottom:10px; } + .fault-summary-heading h2 { font-size:13px; } + .fault-summary-heading>span { font-size:10px; } + .fault-metrics { gap:8px; } + .fault-metric { height:132px; } + .fault-metric-main { padding:10px; } + .fault-metric-label { gap:6px; } + .fault-metric-icon { width:24px; height:24px; border-radius:6px; } + .fault-metric-icon svg { width:17px; height:17px; } + .fault-metric-count { gap:5px; margin:5px 0 3px; } + .fault-metric-count strong { font-size:26px; } + .fault-metric-link { padding-top:4px; } + .fault-open-breakdown { margin:0 8px 8px; padding-top:6px; gap:3px; } + .fault-submetric { flex-direction:row; justify-content:center; gap:4px; min-height:32px; padding:4px 2px; } + .fault-submetric strong { font-size:14px; } + .fault-composition-plus { font-size:14px; } + .fault-overdue-subset { top:41px; right:8px; min-height:32px; padding:4px; gap:3px; align-items:center; } + .fault-overdue-subset .fault-overdue-prefix { display:none; } + .fault-overdue-subset strong { font-size:14px; } + .fault-page>.fault-summary:first-of-type>.fault-summary-note { margin-top:8px; font-size:10px; line-height:1.5; } +} +@media(max-width:359px) { + .fault-open-breakdown { margin-right:6px; margin-left:6px; gap:2px; } + .fault-submetric { gap:2px; padding-right:0; padding-left:0; font-size:9px; white-space:nowrap; } + .fault-submetric strong { font-size:13px; } + .fault-composition-plus { font-size:12px; } +} + +.fault-weekly-date { display:inline-flex; align-items:center; gap:6px; } +.fault-weekly-table tr.fault-weekly-today { background:#f5f8ff; } +.fault-weekly-today td:first-child { box-shadow:inset 2px 0 #bfd0ee; } +.fault-today-label { padding:1px 5px; border-radius:4px; background:#e8effb; color:#536d96; font-size:10px; line-height:1.6; font-weight:500; } +@media(max-width:767px) { .fault-weekly-date { gap:4px; }.fault-today-label { padding:1px 4px; font-size:9px; } } + +/* Short, single-pass entrance: no bounce, list staggering, or animated blur. */ +.fault-analysis-overlay { animation:fault-backdrop-enter 160ms ease-out backwards; } +.fault-analysis-dialog { --fault-enter-y:12px; --fault-enter-scale:.985; transform-origin:50% 60%; animation:fault-dialog-enter 260ms cubic-bezier(.16,1,.3,1) backwards; } +@keyframes fault-backdrop-enter { from { opacity:0; } to { opacity:1; } } +@keyframes fault-dialog-enter { from { opacity:0; transform:translateY(var(--fault-enter-y)) scale(var(--fault-enter-scale)); } to { opacity:1; transform:none; } } +@media(max-width:767px) { .fault-analysis-dialog { --fault-enter-y:8px; --fault-enter-scale:1; animation-duration:220ms; } } +@media(prefers-reduced-motion:reduce) { .fault-analysis-overlay,.fault-analysis-dialog { animation:none; } } + +/* Scoped Apple-style semantic grays; keep the surrounding BI theme unchanged. */ +.fault-analysis-loading { --loading-label:rgba(60,60,67,.72); --loading-fill:rgba(118,118,128,.09); --loading-line:rgba(60,60,67,.08); --loading-radius:12px; padding:8px 0 4px; min-height:340px; } +.fault-loading-status { display:flex; align-items:center; justify-content:center; gap:10px; min-height:40px; margin-bottom:20px; color:var(--loading-label); font-size:12px; letter-spacing:.01em; } +.fault-loading-spinner { width:24px; height:24px; flex-shrink:0; } +.fault-loading-spinner rect { opacity:.2; animation:fault-spinner-tick 1s linear infinite; } +@keyframes fault-spinner-tick { from { opacity:1; } to { opacity:.15; } } +.fault-loading-skeleton { animation:fault-skeleton-breathe 1.8s ease-in-out infinite; } +@keyframes fault-skeleton-breathe { 0%,100% { opacity:.65; } 50% { opacity:1; } } +.fault-loading-block { display:block; height:10px; border-radius:5px; background:var(--loading-fill); } +.fault-loading-controls { padding:16px; border:1px solid var(--loading-line); border-radius:var(--loading-radius); } +.fault-loading-title { width:84px; margin-bottom:14px; } +.fault-loading-controls>div { display:flex; flex-wrap:wrap; gap:8px; } +.fault-loading-controls>div>span { width:88px; height:28px; border-radius:7px; } +.fault-loading-controls>div>span:nth-child(2n) { width:106px; } +.fault-loading-summary { display:flex; align-items:center; gap:16px; padding:20px 0; } +.fault-loading-summary>span:first-child { width:128px; height:18px; }.fault-loading-summary>span:last-child { width:66px; } +.fault-loading-table { border:1px solid var(--loading-line); border-radius:var(--loading-radius); overflow:hidden; } +.fault-loading-table-head,.fault-loading-row { display:grid; grid-template-columns:2fr 1fr 1fr; align-items:center; gap:24px; padding:16px; } +.fault-loading-table-head { background:var(--loading-fill); } +.fault-loading-row { border-top:1px solid var(--loading-line); } +.fault-loading-table .fault-loading-block { width:60%; max-width:130px; } +.fault-loading-row:nth-child(2n)>.fault-loading-block:first-child { width:80%; } +@media(max-width:767px) { .fault-loading-status { margin-bottom:16px; }.fault-loading-controls { padding:12px; }.fault-loading-controls>div { flex-wrap:nowrap; overflow:hidden; }.fault-loading-controls>div>span { flex-shrink:0; width:76px; }.fault-loading-table-head,.fault-loading-row { padding:16px 12px; gap:16px; } } +@media(prefers-reduced-motion:reduce) { .fault-loading-spinner rect,.fault-loading-skeleton { animation:none; }.fault-loading-spinner rect:nth-child(3n) { opacity:.65; } } +@media(prefers-contrast:more) { .fault-analysis-loading { --loading-label:#334155; --loading-line:#94a3b8; --loading-fill:rgba(118,118,128,.18); } } + +.fault-report-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } +.fault-report-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; gap:4px; } +.fault-report-actions .analysis-export-action button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:32px; border:1px solid #dbe4f0; border-radius:7px; background:#fff; color:#475569; padding:6px 10px; white-space:nowrap; } +.fault-report-actions .analysis-export-action button:hover:not(:disabled) { color:#2563eb; border-color:#93c5fd; background:#eff6ff; } +.fault-report-actions .analysis-export-action small { font-size:10px; color:#dc2626; } +@media(max-width:767px) { .fault-trend-heading { flex-wrap:wrap; gap:8px; }.fault-report-actions { gap:6px; margin-left:auto; }.fault-report-actions .analysis-export-action button { min-height:40px; padding:7px 8px; font-size:10px; }.fault-report-actions .fault-weekly-unit { display:none; } } diff --git a/src/modules/assets/faults/report-export.test.ts b/src/modules/assets/faults/report-export.test.ts new file mode 100644 index 0000000..17bdbbd --- /dev/null +++ b/src/modules/assets/faults/report-export.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import ExcelJS from 'exceljs'; +import { createFaultReportWorkbook, type FaultReport } from './report-export'; + +const now = new Date('2026-10-13T08:00:00Z'); +async function saved(report: FaultReport) { + const book = await createFaultReportWorkbook(report, undefined, now); + const reopened = new ExcelJS.Workbook(); + await reopened.xlsx.load(await book.xlsx.writeBuffer()); + return reopened.worksheets[0]; +} + +test('trend export preserves monthly range, real dates, cohort counts and weighted closure rate after serialization', async () => { + const sheet = await saved({ kind: 'trend', data: { + start: '2026-09-14', end: '2026-10-13', granularity: 'month', updatedAt: now.toISOString(), + totals: { reported: 10, closed: 7, unclosed: 3 }, + periods: [{ period: '2026-09', reported: 8, closed: 5, unclosed: 3 }, { period: '2026-10', reported: 2, closed: 2, unclosed: 0 }], + } }); + assert.match(String(sheet.getCell('A2').value), /2026-09-14 至 2026-10-13.*按月/); + assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-01T00:00:00.000Z'); + assert.equal(sheet.getCell('A10').numFmt, 'yyyy-mm'); + assert.equal(sheet.getCell('B11').value, 8); + assert.equal(sheet.getCell('C11').value, 5); + assert.equal(sheet.getCell('D11').value, 3); + assert.deepEqual(sheet.getCell('B12').value, { formula: 'SUM(B10:B11)', result: 10 }); + assert.deepEqual(sheet.getCell('E12').value, { formula: 'IFERROR(C12/B12,0)', result: .7 }); + assert.equal(sheet.autoFilter, 'A9:E11'); + assert.equal(sheet.views[0].state, 'frozen'); + assert.equal(sheet.getCell('A9').fill.type, 'pattern'); +}); + +test('weekly export keeps independent solved counts, descending dates and excludes receipt metrics', async () => { + const sheet = await saved({ kind: 'weekly', data: { + start: '2026-10-12', end: '2026-10-13', updatedAt: now.toISOString(), + totals: { reported: 3, closed: 9, received: 99 }, + days: [{ date: '2026-10-12', reported: 1, closed: 3, received: 90 }, { date: '2026-10-13', reported: 2, closed: 6, received: 9 }], + } }); + assert.deepEqual([1, 2, 3, 4].map(col => sheet.getCell(9, col).value), ['日期', '星期', '新增故障(单)', '解决故障(单)']); + assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-13T00:00:00.000Z'); + assert.match(sheet.getCell('A10').numFmt, /今天/); + assert.equal(sheet.getCell('B10').value, '周二'); + assert.equal(sheet.getCell('D10').value, 6); + assert.deepEqual(sheet.getCell('D12').value, { formula: 'SUM(D10:D11)', result: 9 }); + assert.deepEqual(sheet.getCell('C6').value, { formula: 'SUM(D10:D11)', result: 9 }); + assert.equal(sheet.columnCount, 4); +}); + +test('zero-data trend exports valid guarded ratios and empty reports avoid inverted SUM ranges', async () => { + for (const periods of [[], [{ period: '2026-10-13', reported: 0, closed: 0, unclosed: 0 }]]) { + const sheet = await saved({ kind: 'trend', data: { + start: '2026-10-13', end: '2026-10-13', granularity: 'day', updatedAt: now.toISOString(), + totals: { reported: 0, closed: 0, unclosed: 0 }, periods, + } }); + const total = 10 + periods.length; + assert.equal(sheet.getCell(`E${total}`).formula, `IFERROR(C${total}/B${total},0)`); + assert.equal(sheet.getCell(`B${total}`).formula, periods.length ? 'SUM(B10:B10)' : '0'); + assert.equal(sheet.getCell('A6').result, 0); + } +}); diff --git a/src/modules/assets/faults/report-export.ts b/src/modules/assets/faults/report-export.ts new file mode 100644 index 0000000..3b39932 --- /dev/null +++ b/src/modules/assets/faults/report-export.ts @@ -0,0 +1,140 @@ +import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults'; +import type { Cell, Worksheet } from 'exceljs'; + +export type FaultReport = { kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }; +interface ReportChartImage { dataUrl: string; width: number; height: number } +const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' }; +const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } }); +const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`); +const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); +function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) { + if (to > from) sheet.mergeCells(row, from, row, to); + const cell = sheet.getCell(row, from); cell.value = value; return cell; +} + +/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */ +export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) { + const ExcelJS = (await import('exceljs')).default; + const book = new ExcelJS.Workbook(); + book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now; + const trend = report.kind === 'trend'; + const title = trend ? '故障处理趋势' : '故障运维周报'; + const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)']; + const columns = headers.length; + const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } }); + sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width })); + const headerRow = 9; + const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse(); + const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1; + const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10); + const bottom = totalRow + 3; + for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) { + const cell = sheet.getCell(row, col); + cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } }; + cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' }; + } + const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`); + titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; + titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48; + merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`); + const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; + sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10; + const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0'; + const cards = trend + ? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }] + : [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }]; + for (const card of cards) { + const label = merged(sheet, 5, card.from, card.to, card.label); + const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value }); + for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; } + label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } }; + value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0'; + } + sheet.getRow(5).height = 25; sheet.getRow(6).height = 40; + if (chartImage) { + const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } }); + chart.columns = headers.map(() => ({ width: 900 / columns / 7 })); + chart.properties.defaultRowHeight = 24; + const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`); + heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48; + const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`); + period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } }; + const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障'); + key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; + const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' }); + const scale = Math.min(900 / chartImage.width, 360 / chartImage.height); + const width = chartImage.width * scale, height = chartImage.height * scale; + // Keep circles and labels proportional for both desktop and mobile snapshots. + chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' }); + const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`); + caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; + chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`; + } + headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; }); + sheet.getRow(headerRow).height = 30; + if (trend) [...report.data.periods].reverse().forEach((row, i) => { + const n = start + i; + sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }]; + sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd'; + sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"'; + }); + else [...report.data.days].reverse().forEach((row, i) => { + sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed]; + sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd'; + }); + for (let row = start; row <= end; row++) { + const date = sheet.getCell(row, 1).value as Date; + const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day'); + sheet.getRow(row).height = 26; + for (let col = 1; col <= columns; col++) { + const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white); + cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } }; + cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' }; + cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } }; + if (col > (trend ? 1 : 2) && !(trend && col === 5)) { + cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green; + cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } }; + } + } + } + sheet.getCell(totalRow, 1).value = '合计'; + cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; }); + if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; } + for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; } + sheet.getRow(totalRow).height = 30; + const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。'); + note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36; + const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } }; + sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } }; + sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }]; + sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`; + sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`; + sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页'; + return book; +} + +async function chartImage(svg: SVGSVGElement): Promise { + const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800; + const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240; + const clone = svg.cloneNode(true) as SVGSVGElement; + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height)); + clone.style.fontFamily = 'Arial, sans-serif'; + const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' })); + try { + const image = new Image(); + await new Promise((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; }); + const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2); + const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败'); + ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height); + return { dataUrl: canvas.toDataURL('image/png'), width, height }; + } finally { URL.revokeObjectURL(url); } +} +export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) { + const png = svg ? await chartImage(svg) : undefined; + const book = await createFaultReportWorkbook(report, png); + const bytes = await book.xlsx.writeBuffer(); + const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })); + const link = document.createElement('a'); link.href = url; + link.download = `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`; + document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000); +} diff --git a/src/modules/assets/useMobileLayout.ts b/src/modules/assets/useMobileLayout.ts new file mode 100644 index 0000000..305b592 --- /dev/null +++ b/src/modules/assets/useMobileLayout.ts @@ -0,0 +1,12 @@ +import { useSyncExternalStore } from 'react'; + +const query = '(max-width: 767px)'; +const subscribe = (notify: () => void) => { + const media = window.matchMedia(query); + media.addEventListener('change', notify); + return () => media.removeEventListener('change', notify); +}; +/** Keep the two mobile asset surfaces on the same breakpoint. */ +export function useMobileLayout() { + return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false); +} diff --git a/src/server/app.ts b/src/server/app.ts index 843492f..ba801d5 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -13,6 +13,7 @@ import schedulingRouter from './routes/scheduling/index.js'; import vehicleHeatmapRouter from './routes/vehicle-heatmap.js'; import vehiclesRouter from './routes/vehicles.js'; import assetAssistantRouter from './routes/asset-assistant/index.js'; +import assetFaultRouter from './routes/asset-faults/index.js'; /** * 创建 HTTP 应用,但不监听端口、不访问数据库,也不启动后台任务。 @@ -30,6 +31,7 @@ export function createApp(): Hono { app.use('/api/*', createPreviewMiddleware()); app.route('/api/vehicles', vehiclesRouter); + app.route('/api/asset-faults', assetFaultRouter); app.route('/api/asset-assistant', assetAssistantRouter); app.route('/api/mileage', mileageRouter); app.route('/api/scheduling', schedulingRouter); diff --git a/src/server/routes/asset-faults/index.ts b/src/server/routes/asset-faults/index.ts new file mode 100644 index 0000000..834b85b --- /dev/null +++ b/src/server/routes/asset-faults/index.ts @@ -0,0 +1,90 @@ +import { Hono } from 'hono'; +import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js'; +import type { AuthUser } from '../../auth/types.js'; +import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js'; +import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js'; +import { loadFaults } from './repository.js'; +import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js'; + +export function createFaultRouter(load = loadFaults) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + for (const path of ['/trend', '/trend-analysis'] as const) { + app.get(path, async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + let query; + try { query = parseFaultTrendQuery(c.req.query()); } + catch (error) { return c.json({ error: (error as Error).message }, 400); } + try { + const data = await load(); + const rows = visibleFaults(data.rows, user); + c.header('Cache-Control', 'no-store'); + return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt)); + } catch (error) { + console.error('Fault trend failed:', error instanceof Error ? error.message : 'unknown'); + return c.json({ error: '故障趋势暂时不可用,请稍后重试' }, 503); + } + }); + } + for (const path of ['/weekly-report', '/weekly-report-analysis'] as const) { + app.get(path, async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + let query; + try { query = parseFaultWeeklyQuery(c.req.query()); } + catch (error) { return c.json({ error: (error as Error).message }, 400); } + try { + const data = await load(); + const rows = visibleFaults(data.rows, user); + c.header('Cache-Control', 'no-store'); + return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt)); + } catch (error) { + console.error('Fault weekly report failed:', error instanceof Error ? error.message : 'unknown'); + return c.json({ error: '故障运维周报暂时不可用,请稍后重试' }, 503); + } + }); + } + app.get('/overview', async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + try { + const data = await load(); + c.header('Cache-Control', 'no-store'); + return c.json(faultOverview(visibleFaults(data.rows, user), data.updatedAt)); + } catch (error) { + console.error('Fault overview failed:', error instanceof Error ? error.message : 'unknown'); + return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503); + } + }); + app.get('/analysis', async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + const metric = c.req.query('metric') || ''; + if (!Object.hasOwn(FAULT_OVERVIEW_METRICS, metric)) return c.json({ error: '无效统计指标' }, 400); + try { + const data = await load(); + c.header('Cache-Control', 'no-store'); + return c.json(faultAnalysis(visibleFaults(data.rows, user), metric as FaultOverviewMetric, data.updatedAt)); + } catch (error) { + console.error('Fault analysis failed:', error instanceof Error ? error.message : 'unknown'); + return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503); + } + }); + app.get('/', async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + let query; + try { query = parseFaultQuery(c.req.query()); } + catch (error) { return c.json({ error: (error as Error).message }, 400); } + try { + const data = await load(); + c.header('Cache-Control', 'no-store'); + return c.json(analyzeFaults(visibleFaults(data.rows, user, query.subject), query, data.updatedAt)); + } catch (error) { + console.error('Fault BI query failed:', error instanceof Error ? error.message : 'unknown'); + return c.json({ error: '故障数据暂时不可用,请稍后重试' }, 503); + } + }); + return app; +} +export default createFaultRouter(); diff --git a/src/server/routes/asset-faults/model.test.ts b/src/server/routes/asset-faults/model.test.ts new file mode 100644 index 0000000..fac4b38 --- /dev/null +++ b/src/server/routes/asset-faults/model.test.ts @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { analyzeFaults, faultAnalysis, faultHandlers, faultInventory, mapFault, normalizeFaultStatus, parseFaultQuery, visibleFaults, type ScopedFault } from './model.js'; +import { createFaultRouter } from './index.js'; +import type { AuthUser } from '../../auth/types.js'; + +const full: AuthUser = { userId: 'm1', userName: 'User', loginName: 'user', depCode: '', depName: '业务一部', permissionLevel: 'full', roles: [] }; +function fault(id: string, overrides: Partial = {}): ScopedFault { + return { assetCell: mapFault({ id }, new Map()).assetCell, id, code: id, plate: '测试车', description: '', reportedAt: '2026-10-01 12:00:00', archivedAt: null, deadlineAt: null, receivedAt: null, + city: '广州市', status: '待处理', inventory: '不适用', customer: 'customer1', customerLabel: '测试***户', department: '业务一部', + handlers: [{ value: 'id:1', label: '甲' }], subject: 'A', departmentName: '业务一部', managerId: 'm1', missingVehicle: false, ...overrides }; +} +const query = (params: Record = {}) => parseFaultQuery({ start: '2026-10-13', end: '2026-10-13', ...params }); + +test('跨日完结独立于提报日期,全部挂单不受日期筛选影响,挂起和审核中均未闭环', () => { + const rows = [fault('old'), fault('suspended', { status: '挂起' }), fault('review', { status: '处理中' }), + fault('closed', { status: '已归档', archivedAt: '2026-10-13 00:00:00' }), + fault('new', { reportedAt: '2026-10-13 23:59:59' }), fault('tomorrow', { status: '已归档', archivedAt: '2026-10-14 00:00:00' })]; + const result = analyzeFaults(rows, query(), 'now'); + assert.deepEqual(result.totals, { reported: 1, closed: 1, open: 4 }); + assert.equal(result.details.total, 4); + assert.deepEqual(result.daily, [{ date: '2026-10-13', reported: 1, closed: 1 }]); + assert.equal(analyzeFaults(rows, query({ start: '2025-01-01', end: '2025-01-02', day: '2025-01-01' }), 'now').totals.open, 4); + assert.equal(analyzeFaults(rows, query({ metric: 'closed' }), 'now').details.rows[0].id, 'closed'); +}); + +test('多人负责按人员计单,总计去重;人员筛选与人员分组下钻一致', () => { + const row = fault('shared', { handlers: [{ value: 'id:1', label: '甲' }, { value: 'id:2', label: '乙' }] }); + const result = analyzeFaults([row], query({ groupBy: 'handler' }), 'now'); + assert.equal(result.totals.open, 1); assert.equal(result.groups.reduce((sum, r) => sum + r.open, 0), 2); + assert.equal(analyzeFaults([row], query({ handler: 'id:2' }), 'now').details.total, 1); + assert.equal(analyzeFaults([row], query({ groupBy: 'handler', groupValue: 'id:2' }), 'now').details.total, 1); +}); + +test('处置人优先来源任务工单,兼容历史姓名,保留 BIGINT 精度并去重', () => { + const directory = new Map([['1234567890123456789', '当前姓名']]); + const row = { work_ids: '[1234567890123456789,1234567890123456789]', work_names: '["旧名","旧名"]', handler_id: '2', handler_name: '非当前处置人' }; + assert.deepEqual(faultHandlers(row, directory), [{ value: 'id:1234567890123456789', label: '当前姓名' }]); + assert.deepEqual(faultHandlers({ work_names: '["历史姓名"]', handler_id: '2' }, directory), [{ value: 'name:历史姓名', label: '历史姓名' }]); + assert.deepEqual(faultHandlers({ handler_ids: 'bad-json', handler_id: '99' }, directory), [{ value: 'id:99', label: '历史处理人(99)' }]); + assert.deepEqual(faultHandlers({ handler_ids: '[99,99]', handler_names: '["历史[123]","另一姓名"]' }, directory), [{ value: 'id:99', label: '历史[123]' }]); + assert.deepEqual(faultHandlers({}, directory), [{ value: 'unassigned', label: '未指派' }]); +}); + +test('库存先判断运营状态,再细分;未知状态不自动计为归档', () => { + assert.equal(faultInventory('1', '14', true), '不适用'); + assert.equal(faultInventory('3', '14', true), '异动'); + assert.equal(faultInventory('4', '4', true), '待交车'); + assert.equal(faultInventory('3', '2', true), '可运营'); + assert.equal(faultInventory('4', '2', true), '待运营'); + assert.equal(faultInventory('5', '2', true), '不适用'); + assert.equal(faultInventory(null, null, false), '未知'); + assert.equal(normalizeFaultStatus('待处置'), '待处理'); + assert.equal(normalizeFaultStatus('已挂起'), '挂起'); + assert.equal(analyzeFaults([fault('odd', { status: '其他状态' })], query(), 'now').totals.open, 0); +}); + +test('缺失车辆和日期保留总量及可用归档事件,客户名称脱敏且不传递服务端权限字段', () => { + const row = mapFault({ id: 'lost', fault_code: 'F', customer_name: '需要脱敏的客户名称', handling_status: '已归档', archived_at: '2026-10-13 08:00:00' }, new Map()); + const result = analyzeFaults([row], query({ metric: 'closed' }), 'now'); + assert.deepEqual(result.totals, { reported: 0, closed: 1, open: 0 }); + assert.equal(result.quality.missingReport, 1); assert.equal(result.quality.missingVehicle, 1); + assert.equal(result.details.rows[0].city, '未知'); + assert.ok(!JSON.stringify(result).includes('需要脱敏的客户名称')); + assert.ok(!('managerId' in result.details.rows[0])); +}); + +test('故障历史使用严格人员/部门权限,库存不放宽;全权限保留失联车辆', () => { + const rows = [fault('mine'), fault('private', { managerId: 'm2', departmentName: '其他部', department: '其他部', inventory: '可运营' }), fault('lost', { managerId: '', departmentName: '', missingVehicle: true })]; + assert.equal(visibleFaults(rows, full).length, 3); + assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'personal' }).map(r => r.id), ['mine']); + assert.deepEqual(visibleFaults(rows, { ...full, permissionLevel: 'department' }).map(r => r.id), ['mine']); + assert.equal(visibleFaults(rows, { ...full, permissionLevel: 'department', depName: '' }).length, 0); + assert.equal(visibleFaults(rows, full, 'B').length, 0); +}); + +test('日期合法性、范围限制和分页边界', () => { + const invalid: Record[] = [{ start: '2026-02-30' }, { start: '2027-01-01' }, { start: '2023-01-01' }, { page: '-1' }, { groupBy: '__proto__' }, { metric: 'unknown' }, { day: '2026-10-14' }]; + for (const params of invalid) assert.throws(() => query(params)); + const rows = Array.from({ length: 51 }, (_, i) => fault(String(i))); + const result = analyzeFaults(rows, query({ page: '99' }), 'now'); + assert.equal(result.details.page, 2); assert.equal(result.details.rows.length, 1); +}); + +test('API 在汇总、筛选项和明细前应用权限,未认证和无效日期不查询数据库', async () => { + let calls = 0; + const router = createFaultRouter(async () => { calls++; return { updatedAt: 'now', rows: [fault('mine'), fault('secret', { managerId: 'm2', city: '保密城市' })] }; }); + assert.equal((await router.request('/')).status, 401); assert.equal(calls, 0); + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); }); + app.route('/faults', router); + assert.equal((await app.request('/faults?start=bad')).status, 400); assert.equal(calls, 0); + const response = await app.request('/faults?start=2026-10-13&end=2026-10-13'); + assert.equal(response.status, 200); assert.equal(response.headers.get('Cache-Control'), 'no-store'); + const body = await response.json(); assert.equal(body.totals.open, 1); assert.ok(!JSON.stringify(body).includes('保密城市')); +}); + +test('四个总指标使用北京时间自然日,全部台账包含未知状态和无日期故障', async () => { + const { faultOverview, faultAnalysis } = await import('./model.js'); + const rows = [fault('old'), fault('today', { reportedAt: '2026-10-14 00:00:00' }), + fault('closed', { status: '已归档', archivedAt: '2026-10-14 23:59:59' }), + fault('unknown', { status: '未知状态', reportedAt: null, missingVehicle: true })]; + const at = '2026-10-13T16:00:00Z'; + const summary = faultOverview(rows, at); + assert.equal(summary.date, '2026-10-14'); + assert.deepEqual(summary.totals, { reported: 1, closed: 1, open: 2, waiting: 2, processing: 0, suspended: 0, overdue: 0, ledger: 4 }); + for (const metric of ['reported', 'closed', 'open', 'ledger'] as const) { + const analysis = faultAnalysis(rows, metric, at); + assert.equal(analysis.rows.length, summary.totals[metric]); + assert.equal(analysis.rows.reduce((sum, row) => sum + row.cell.quantity, 0), summary.totals[metric]); + assert.ok(!JSON.stringify(analysis).includes('managerId')); + } + assert.equal(faultOverview(rows, '2026-10-13T15:59:59Z').totals.reported, 0); +}); + +test('故障下钻与资产维度映射同步,同车多故障逐单统计,脱敏客户保持独立', async () => { + const { faultAnalysis } = await import('./model.js'); + const { getAvailableDimensions, getCustomAnalysis } = await import('../../../modules/assets/analytics-model.js'); + const raw = { matched_vehicle_id: 'V1', model_name: '公务用车/小客车', brand: 'denza', vehicle_source: '0', + city_name: '上海市', province_name: '上海市', operation_status: '2', business_dept: '业务部', business_manager: '经理甲' }; + const rows = [mapFault({ ...raw, id: 'F1', customer_name: '客户甲' }, new Map()), mapFault({ ...raw, id: 'F2', customer_name: '客户乙' }, new Map())]; + const data = faultAnalysis(rows, 'ledger', '2026-10-13T06:00:00Z'); + assert.equal(data.rows[0].cell.model, '公务车/挂靠车'); + assert.equal(data.rows[0].cell.department, '公务车'); + assert.equal(data.rows[0].cell.region, '华东'); + assert.equal(data.rows[0].cell.manager, '经理甲'); + assert.equal(data.rows[0].cell.assetDisposition, '自营'); + assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['model'] }).rows[0].count, 2); + assert.equal(getCustomAnalysis(data.rows.map(row => row.cell), { view: 'assets', dimensions: ['customer'] }).rows.length, 2); + assert.equal(getAvailableDimensions('assets').length, 9); +}); + +test('总览与下钻接口先应用权限,无效指标和未认证不查询', async () => { + let calls = 0; + const router = createFaultRouter(async () => { calls++; return { updatedAt: '2026-10-13T06:00:00Z', rows: [fault('mine'), fault('secret', { managerId: 'm2', customer: 'secret-customer', customerLabel: '保密客户' })] }; }); + for (const url of ['/overview', '/analysis?metric=ledger']) assert.equal((await router.request(url)).status, 401); + assert.equal(calls, 0); + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', { ...full, permissionLevel: 'personal' }); await next(); }); + app.route('/faults', router); + assert.equal((await app.request('/faults/analysis?metric=__proto__')).status, 400); + assert.equal(calls, 0); + const summary = await (await app.request('/faults/overview')).json(); + assert.equal(summary.totals.ledger, 1); + const response = await app.request('/faults/analysis?metric=ledger'); + const data = await response.json(); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + assert.equal(data.rows.length, summary.totals.ledger); + assert.ok(!JSON.stringify(data).includes('secret')); +}); + +test('在办按状态细分;逾期以北京时间截止时间严格比较,排除挂起、归档和无截止时间', async () => { + const { faultOverview, faultAnalysis } = await import('./model.js'); + const at = '2026-10-13T06:00:00Z'; + const rows = [fault('waiting-overdue', { deadlineAt: '2026-10-13 13:59:59' }), + fault('processing-overdue', { status: '处理中', deadlineAt: '2026-10-12 14:00:00' }), + fault('boundary', { deadlineAt: '2026-10-13 14:00:00' }), + fault('future', { status: '处理中', deadlineAt: '2026-10-13 14:00:01' }), + fault('missing'), fault('invalid', { deadlineAt: 'invalid' }), + fault('suspended', { status: '挂起', deadlineAt: '2026-01-01 00:00:00' }), + fault('archived', { status: '已归档', deadlineAt: '2026-01-01 00:00:00' })]; + const summary = faultOverview(rows, at); + assert.equal(summary.totals.waiting, 4); + assert.equal(summary.totals.processing, 2); + assert.equal(summary.totals.suspended, 1); + assert.equal(summary.totals.open, 6); + assert.equal(summary.totals.overdue, 2); + assert.equal(summary.totals.open, summary.totals.waiting + summary.totals.processing); + for (const metric of ['open', 'waiting', 'processing', 'suspended', 'overdue'] as const) { + assert.equal(faultAnalysis(rows, metric, at).rows.length, summary.totals[metric]); + } + const openIds = new Set(faultAnalysis(rows, 'open', at).rows.map(r => r.id)); + assert.ok(faultAnalysis(rows, 'overdue', at).rows.every(row => openIds.has(row.id))); + assert.deepEqual(faultAnalysis(rows, 'overdue', at).rows.map(r => r.id), ['waiting-overdue', 'processing-overdue']); +}); + + +test('未关联客户的库存车单独展示,不混入非库存、未知车辆或已有客户', () => { + const make = (extra: Record) => mapFault({ id: 'stock', matched_vehicle_id: 'v1', operation_status: '3', ...extra }, new Map()); + for (const operation_status of ['3', '4', '可运营', '待运营', '在库']) { + const row = make({ operation_status }); + assert.equal(row.customer, 'inventory-unlinked'); + assert.equal(row.assetCell.customer, 'inventory-unlinked'); + assert.equal(row.customerLabel, '库存车'); + } + for (const extra of [{ operation_status: '1' }, { operation_status: '5' }, { operation_status: null }, { matched_vehicle_id: null }]) { + const row = make(extra); assert.equal(row.customer, 'unlinked'); assert.equal(row.customerLabel, '未关联客户'); + } + const assigned = make({ customer_name: '测试客户名称' }); + assert.notEqual(assigned.customer, 'inventory-unlinked');assert.notEqual(assigned.customerLabel, '库存车'); + const result = faultAnalysis([make({}), make({ id: 'other', operation_status: '1' })], 'ledger', '2026-10-13T00:00:00Z'); + assert.equal(result.customerLabels['inventory-unlinked'], '库存车');assert.equal(result.customerLabels.unlinked, '未关联客户');assert.equal(result.rows.length, 2); +}); diff --git a/src/server/routes/asset-faults/model.ts b/src/server/routes/asset-faults/model.ts new file mode 100644 index 0000000..53b0cbc --- /dev/null +++ b/src/server/routes/asset-faults/model.ts @@ -0,0 +1,191 @@ +import { createHash } from 'node:crypto'; +import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js'; +import type { AuthUser } from '../../auth/types.js'; +import { filterByPermission, maskCustomerName } from '../../auth/permissions.js'; +import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js'; +import { deriveModelTag, deriveType, mapMacroRegion } from '../vehicles/model.js'; + +export type RawFault = Record; +export interface ScopedFault extends FaultRecord { + subject: string; departmentName: string; managerId: string; missingVehicle: boolean; + assetCell: AssetAnalysisCell; +} +export function normalizeFaultStatus(value: unknown): string { + const status = String(value ?? '').trim(); + if (['', '待处理', '待处置', '未处理'].includes(status)) return '待处理'; + if (['处理中', '处置中', '处理'].includes(status)) return '处理中'; + if (['挂起', '已挂起'].includes(status)) return '挂起'; + return status; +} +export const isOpenFault = (status: string) => ['待处理', '处理中', '挂起'].includes(status); +function array(value: unknown, preserveIds = false): unknown[] { + try { + // BIGINT IDs in legacy longtext arrays must never pass through floating point. + const parsed = typeof value === 'string' ? JSON.parse(preserveIds ? value.replace(/([\[,]\s*)(\d+)(?=\s*[,\]])/g, '$1"$2"') : value) : value; + return Array.isArray(parsed) ? parsed : []; + } catch { return []; } +} +const text = (value: unknown) => String(value ?? '').trim(); +const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value)); +export function faultHandlers(row: RawFault, directory: Map): FaultOption[] { + const candidates = [ + [array(row.work_ids, true), array(row.work_names)], + [[row.work_id], [row.work_name]], + [array(row.handler_ids, true), array(row.handler_names)], + [[row.handler_id], [row.handler_name]], + ]; + const [ids, names] = candidates.find(([ids, names]) => ids.some(positiveId) || names.some(v => text(v))) ?? [[], []]; + const handlers = new Map(); + for (let i = 0; i < Math.max(ids.length, names.length); i++) { + const id = positiveId(ids[i]) ? text(ids[i]) : ''; + const name = text(names[i]); + if (!id && !name) continue; + const value = id ? `id:${id}` : `name:${name}`; + if (!handlers.has(value)) handlers.set(value, { value, label: id ? directory.get(id) || name || `历史处理人(${id})` : name }); + } + return handlers.size ? [...handlers.values()] : [{ value: 'unassigned', label: '未指派' }]; +} +export function faultInventory(operation: unknown, vehicle: unknown, exists: boolean): string { + if (!exists || !text(operation)) return '未知'; + if (!['3', '4', '可运营', '待运营', '在库'].includes(text(operation))) return '不适用'; + if (text(vehicle) === '14') return '异动'; + if (text(vehicle) === '4') return '待交车'; + return ['3', '可运营'].includes(text(operation)) ? '可运营' : ['4', '待运营'].includes(text(operation)) ? '待运营' : '未分类'; +} +export function mapFault(row: RawFault, directory: Map): ScopedFault { + const customer = text(row.customer_name); + const city = text(row.city_name) || text(row.operation_city) || '未知'; + const rawModel = text(row.model_name); + const brands: Record = { hyundai: /帕力安|冷链|双飞翼/.test(rawModel) ? '帕力安牌' : '现代', yuejin: '跃进', feichi: '飞驰', sulong: '苏龙', higer: '海格', dongfeng: '东风', yutong: '宇通', chufeng: '楚风', tonghua: '通华', maxus: '大通', mingwei: '明威', wanfeng: '万风', shujie: '舒捷', denza: '腾势', hongyan: '红岩', 'yuanchang brand': '远程牌', others: '其他' }; + const ownership = ({ '0': '自有', '1': '挂靠', '2': '外租', '3': '自有' } as Record)[text(row.vehicle_source)] || text(row.actual_ownership); + const model = deriveModelTag(brands[text(row.brand)] || text(row.brand), rawModel, text(row.body_color), ownership, text(row.rental_company)); + const type = deriveType(rawModel, text(row.model_type)); + const operation = text(row.operation_status); + const operating = ['1', '2', '租赁', '自营'].includes(operation); + const inventory = ['3', '4', '可运营', '待运营', '在库'].includes(operation); + const unlinkedInventory = !customer && !!row.matched_vehicle_id && inventory; + const customerKey = customer ? createHash('sha256').update(customer).digest('hex') : unlinkedInventory ? 'inventory-unlinked' : 'unlinked'; + const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营', '5': '退出运营' } as Record)[operation] || operation || '未分类'; + const inventoryLabel = faultInventory(operation, row.vehicle_status, !!row.matched_vehicle_id); + const assetCell: AssetAnalysisCell = { + type: !row.matched_vehicle_id ? '未知车型' : type === '4.5T' ? (model.includes('冷链') ? '4.5T冷链' : '4.5T普货') : ['18T', '49T'].includes(type) ? type : '其他', + model, city, region: mapMacroRegion(text(row.province_name), city), + assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other', + assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : '自营') : '其他', operationKind, + inventoryState: ({ '异动': 'abnormal', '待交车': 'pending', '可运营': 'available', '待运营': 'awaiting', '未分类': 'unclassified' } as Record)[inventoryLabel] || 'not-applicable', + department: model === '公务车/挂靠车' ? '公务车' : text(row.business_dept) || '未分配部门', + manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1, + }; + return { + id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆', + description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null, + city, status: normalizeFaultStatus(row.handling_status), assetCell, + inventory: faultInventory(row.operation_status, row.vehicle_status, !!row.matched_vehicle_id), + customer: customerKey, + customerLabel: maskCustomerName(customer) || (unlinkedInventory ? '库存车' : '未关联客户'), department: text(row.business_dept) || '未关联部门', + departmentName: text(row.business_dept), managerId: text(row.manager_id), subject: text(row.subject), + missingVehicle: !row.matched_vehicle_id, handlers: faultHandlers(row, directory), + }; +} +export function visibleFaults(rows: ScopedFault[], user: AuthUser, subject?: string): ScopedFault[] { + // Fault history never inherits the public-inventory exception for current assets. + const candidates = user.permissionLevel === 'full' ? rows : rows.filter(row => !row.missingVehicle); + const scoped = user.permissionLevel === 'department' && !user.depName ? [] : filterByPermission(candidates, user); + return scoped.filter(row => !subject || row.subject === subject); +} +export function parseFaultQuery(params: Record, now = new Date()): FaultQuery { + const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10); + const start = params.start || new Date(Date.parse(today) - 29 * 86400000).toISOString().slice(0, 10); + const end = params.end || today; + const valid = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && Number.isFinite(Date.parse(s)) && new Date(s).toISOString().slice(0, 10) === s; + if (!valid(start) || !valid(end) || start > end || (Date.parse(end) - Date.parse(start)) / 86400000 > 365) throw new Error('日期范围须为有效日期,且不超过366天'); + if (params.day && (!valid(params.day) || params.day < start || params.day > end)) throw new Error('明细日期超出统计范围'); + const groupBy = params.groupBy || 'city'; + if (!(groupBy in FAULT_DIMENSIONS) || !Object.hasOwn(FAULT_DIMENSIONS, groupBy)) throw new Error('无效统计维度'); + const metric = params.metric || 'open'; + if (!['reported', 'closed', 'open'].includes(metric)) throw new Error('无效统计指标'); + const page = Number(params.page || 1); + if (!Number.isSafeInteger(page) || page < 1) throw new Error('无效页码'); + return { start, end, groupBy: groupBy as FaultDimension, metric: metric as FaultQuery['metric'], page, + day: params.day, groupValue: params.groupValue, subject: params.subject, + filters: Object.fromEntries(Object.keys(FAULT_DIMENSIONS).filter(key => params[key]).map(key => [key, params[key]])) }; +} +const dimensions = Object.keys(FAULT_DIMENSIONS) as FaultDimension[]; +function values(row: ScopedFault, dimension: FaultDimension): FaultOption[] { + return dimension === 'handler' ? row.handlers : [{ value: row[dimension], label: dimension === 'customer' ? row.customerLabel : row[dimension] }]; +} +export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updatedAt: string): FaultResponse { + const options = Object.fromEntries(dimensions.map(d => [d, [...new Map(visible.flatMap(row => values(row, d)).map(o => [o.value, o])).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN'))])) as FaultResponse['options']; + const rows = visible.filter(row => dimensions.every(d => !query.filters[d] || values(row, d).some(o => o.value === query.filters[d]))); + const inRange = (time: string | null) => !!time && time.slice(0, 10) >= query.start && time.slice(0, 10) <= query.end; + const counts = (row: ScopedFault) => ({ reported: Number(inRange(row.reportedAt)), closed: Number(row.status === '已归档' && inRange(row.archivedAt)), open: Number(isOpenFault(row.status)) }); + const totals = { reported: 0, closed: 0, open: 0 }; + const groups = new Map(); + const daily = new Map(); + for (let ts = Date.parse(query.start); ts <= Date.parse(query.end); ts += 86400000) { + const date = new Date(ts).toISOString().slice(0, 10); daily.set(date, { date, reported: 0, closed: 0 }); + } + for (const row of rows) { + const count = counts(row); + for (const key of ['reported', 'closed', 'open'] as const) totals[key] += count[key]; + if (count.reported) daily.get(row.reportedAt!.slice(0, 10))!.reported++; + if (count.closed) daily.get(row.archivedAt!.slice(0, 10))!.closed++; + if (!count.reported && !count.closed && !count.open) continue; + for (const option of values(row, query.groupBy)) { + const group = groups.get(option.value) || { ...option, reported: 0, closed: 0, open: 0 }; + for (const key of ['reported', 'closed', 'open'] as const) group[key] += count[key]; + groups.set(option.value, group); + } + } + const details = rows.filter(row => counts(row)[query.metric] && + (query.metric === 'open' || !query.day || (query.metric === 'reported' ? row.reportedAt : row.archivedAt)?.slice(0, 10) === query.day) && + (!query.groupValue || values(row, query.groupBy).some(o => o.value === query.groupValue))) + .sort((a, b) => (query.metric === 'closed' ? b.archivedAt || '' : b.reportedAt || '').localeCompare(query.metric === 'closed' ? a.archivedAt || '' : a.reportedAt || '') || b.id.localeCompare(a.id)); + const pageSize = 50; + const page = Math.min(query.page, Math.max(1, Math.ceil(details.length / pageSize))); + return { start: query.start, end: query.end, updatedAt, totals, daily: [...daily.values()], + groups: [...groups.values()].sort((a, b) => b.open - a.open || b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN')), options, + quality: { missingReport: rows.filter(r => !r.reportedAt).length, + invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length, + missingVehicle: rows.filter(r => r.missingVehicle).length }, + details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => row), total: details.length, page, pageSize }, + }; +} + +export function faultBusinessDate(updatedAt: string): string { + return new Date(Date.parse(updatedAt) + 8 * 3600000).toISOString().slice(0, 10); +} +export function isOverdueFault(row: FaultRecord, updatedAt: string): boolean { + // OneOS supervisor aggregate: stored deadline only, suspended clock excluded, strict < NOW(). + if (!['待处理', '处理中'].includes(row.status) || !row.deadlineAt) return false; + const deadline = Date.parse(`${row.deadlineAt.replace(' ', 'T')}+08:00`); + return Number.isFinite(deadline) && deadline < Date.parse(updatedAt); +} +export function selectFaultMetric(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, date: string, updatedAt: string) { + return rows.filter(row => { + switch (metric) { + case 'ledger': return true; + // Current BI definition: in progress = waiting + processing; suspended remains in the ledger. + case 'open': return ['待处理', '处理中'].includes(row.status); + case 'waiting': return row.status === '待处理'; + case 'processing': return row.status === '处理中'; + case 'suspended': return row.status === '挂起'; + case 'overdue': return isOverdueFault(row, updatedAt); + case 'reported': return row.reportedAt?.slice(0, 10) === date; + case 'closed': return row.status === '已归档' && row.archivedAt?.slice(0, 10) === date; + } + }); +} +export function faultOverview(rows: ScopedFault[], updatedAt: string): import('../../../shared/assets/faults.js').FaultOverview { + const date = faultBusinessDate(updatedAt); + return { date, updatedAt, totals: Object.fromEntries( + (Object.keys(FAULT_OVERVIEW_METRICS) as import('../../../shared/assets/faults.js').FaultOverviewMetric[]) + .map(metric => [metric, selectFaultMetric(rows, metric, date, updatedAt).length]), + ) as import('../../../shared/assets/faults.js').FaultOverview['totals'] }; +} +export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shared/assets/faults.js').FaultOverviewMetric, updatedAt: string): import('../../../shared/assets/faults.js').FaultAnalysisResponse { + const date = faultBusinessDate(updatedAt); + const selected = selectFaultMetric(rows, metric, date, updatedAt); + return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])), + rows: selected.map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => ({ ...row, cell: assetCell })) }; +} diff --git a/src/server/routes/asset-faults/repository.ts b/src/server/routes/asset-faults/repository.ts new file mode 100644 index 0000000..e64e808 --- /dev/null +++ b/src/server/routes/asset-faults/repository.ts @@ -0,0 +1,67 @@ +import type { RowDataPacket } from 'mysql2'; +import pool from '../../db.js'; +import { mapFault, type RawFault } from './model.js'; + +// OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause. +export const FAULT_SQL = `SELECT + CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status, + DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at, + DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at, + DATE_FORMAT(f.deadline_at, '%Y-%m-%d %H:%i:%s') deadline_at, + DATE_FORMAT(receipt.received_at, '%Y-%m-%d %H:%i:%s') received_at, + CAST(vi.id AS CHAR) matched_vehicle_id, vi.operation_city, + COALESCE(city.NAME, district.NAME) city_name, vi.registered_ownership subject, + COALESCE(province.NAME, vi.province_name) province_name, + vm.brand, vm.model model_name, vm.vehicle_type model_type, vi.body_color, vi.vehicle_source, vi.actual_ownership, vi.rental_company, + vs.operation_status, vs.vehicle_status, lease.customer_name, lease.business_dept, + lease.business_manager, + CAST(COALESCE(contract.business_manager_id, vi.business_id) AS CHAR) manager_id, + CAST(f.inspection_operator_id AS CHAR) handler_id, f.inspection_operator_name handler_name, + f.inspection_operator_ids_json handler_ids, f.inspection_operator_names_json handler_names, + CAST(wo.current_executor_user_id AS CHAR) work_id, wo.current_executor_name work_name, + CAST(wo.current_executor_user_ids_json AS CHAR) work_ids, + CAST(wo.current_executor_names_json AS CHAR) work_names +FROM vehicle_fault_manage f +LEFT JOIN ( + SELECT fault_id, MIN(create_time) received_at + FROM vehicle_fault_operation_log + WHERE del_flag='0' AND action_code='SAVE_PROGRESS' + AND COALESCE(TRIM(before_status), '') IN ('', '待处理', '待处置', '未处理') + AND TRIM(after_status) IN ('处理中', '处置中', '处理') + GROUP BY fault_id +) receipt ON receipt.fault_id=f.id +LEFT JOIN vehicle_info vi ON vi.id=f.vehicle_id AND vi.del_flag='0' +LEFT JOIN vehicle_model vm ON vm.id=vi.vehicle_model_id AND vm.del_flag='0' +LEFT JOIN common_district province ON province.CODE=CONCAT(LEFT(vi.operation_city,2),'0000') COLLATE utf8mb4_unicode_ci AND province.STATUS='VALID' +LEFT JOIN vehicle_status vs ON vs.id=( + SELECT s.id FROM vehicle_status s WHERE s.vehicle_id=vi.id AND s.del_flag=0 + ORDER BY s.update_time DESC, s.id DESC LIMIT 1 +) +LEFT JOIN vehicle_lease_order_record lease ON lease.id=( + SELECT r.id FROM vehicle_lease_order_record r WHERE r.vehicle_id=vi.id AND r.del_flag='0' + ORDER BY r.last_delivery_time DESC, r.id DESC LIMIT 1 +) +LEFT JOIN vehicle_lease_contract_info contract ON contract.id=lease.contract_id AND contract.del_flag='0' +LEFT JOIN common_district district ON district.CODE=vi.operation_city COLLATE utf8mb4_unicode_ci AND district.STATUS='VALID' +LEFT JOIN common_district city ON city.CODE=(CASE + WHEN LEFT(vi.operation_city,2) IN ('11','12','31','50') THEN CONCAT(LEFT(vi.operation_city,2),'0000') + ELSE CONCAT(LEFT(vi.operation_city,4),'00') END) COLLATE utf8mb4_unicode_ci AND city.STATUS='VALID' +LEFT JOIN task_work_order wo ON wo.id=f.source_work_order_id AND wo.del_flag='0' +WHERE f.del_flag='0' AND f.is_submit IN (0,1)`; + +export async function loadFaults() { + const connection = await pool.getConnection(); + try { + await connection.query('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY'); + const [raw] = await connection.query({ sql: FAULT_SQL, timeout: 20000 }); + const [users] = await connection.query({ sql: `SELECT CAST(user_id AS CHAR) id, nick_name name + FROM \`ry-cloud\`.sys_user WHERE del_flag='0' AND status='0' + AND (user_type IS NULL OR user_type='sys_user')`, timeout: 10000 }); + const directory = new Map(users.map(row => [String(row.id), String(row.name || '').trim()])); + // Defensive ID de-duplication keeps imperfect lookup tables from multiplying tickets. + const rows = [...new Map(raw.map(row => [String(row.id), mapFault(row as RawFault, directory)])).values()]; + return { rows, updatedAt: new Date().toISOString() }; + } finally { + try { await connection.rollback(); } finally { connection.release(); } + } +} diff --git a/src/server/routes/asset-faults/trend.test.ts b/src/server/routes/asset-faults/trend.test.ts new file mode 100644 index 0000000..416ecf7 --- /dev/null +++ b/src/server/routes/asset-faults/trend.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { mapFault, type ScopedFault } from './model.js'; +import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js'; +import { createFaultRouter } from './index.js'; +import type { AuthUser } from '../../auth/types.js'; +const at = '2026-10-13T08:00:00Z'; +const row = (id: string, reported: string | null, status: string, archived: string | null = null): ScopedFault => mapFault({ id, reported_at: reported, handling_status: status, archived_at: archived, matched_vehicle_id: 'v1' }, new Map()); +const rows = [row('a', '2026-09-30 23:59:59', '已归档', '2026-10-12 12:00:00'), + row('b', '2026-10-01 00:00:00', '处理中'), row('c', '2026-10-01 08:00:00', '挂起'), + row('d', '2026-10-03 23:59:59', '待处理'), row('before', '2026-09-29 23:59:59', '已归档', '2026-10-02 00:00:00'), + row('after', '2026-10-04 00:00:00', '已归档'), row('missing', null, '已归档')]; +const query = (params: Record = {}) => parseFaultTrendQuery({ start: '2026-09-30', end: '2026-10-03', ...params }); + +test('按提报日统计同批新增的当前闭环情况,跨期归档仍属于原提报批次,空日补零', () => { + const data = faultTrend(rows, query(), at); + assert.deepEqual(data.totals, { reported: 4, closed: 1, unclosed: 3 }); + assert.deepEqual(data.periods.map(p => [p.period, p.reported, p.closed, p.unclosed]), [ + ['2026-09-30', 1, 1, 0], ['2026-10-01', 2, 0, 2], ['2026-10-02', 0, 0, 0], ['2026-10-03', 1, 0, 1], + ]); + for (const period of data.periods) assert.equal(period.reported, period.closed + period.unclosed); + assert.equal(data.periods.reduce((sum, p) => sum + p.reported, 0), data.totals.reported); +}); +test('按月不扩展所选首尾边界,日月总数一致;汇总与各期下钻匹配', () => { + const data = faultTrend(rows, query({ granularity: 'month' }), at); + assert.deepEqual(data.totals, faultTrend(rows, query(), at).totals); + assert.deepEqual(data.periods.map(p => [p.period, p.reported]), [['2026-09', 1], ['2026-10', 3]]); + for (const granularity of ['day', 'month']) { + for (const period of faultTrend(rows, query({ granularity }), at).periods) { + for (const [status, key] of [['all', 'reported'], ['closed', 'closed'], ['unclosed', 'unclosed']] as const) { + assert.equal(faultTrendAnalysis(rows, query({ granularity, period: period.period, status }), at).rows.length, period[key]); + } + } + } +}); +test('空范围、闰日、非法周期及370天范围限制', () => { + assert.equal(faultTrend([], query(), at).periods.length, 4); + assert.deepEqual(faultTrend([], query(), at).totals, { reported: 0, closed: 0, unclosed: 0 }); + assert.doesNotThrow(() => query({ start: '2024-02-29', end: '2024-02-29' })); + const invalid: Record[] = [{ start: '2026-02-30' }, { end: '2026-01-01' }, { granularity: 'year' }, { status: '__proto__' }, + { period: '2026-09-29' }, { granularity: 'month', period: '2026-13' }, { start: '2025-01-01' }]; + for (const params of invalid) assert.throws(() => query(params)); +}); +test('趋势和下钻都先应用人员权限,未认证及无效参数不查询数据库', async () => { + let calls = 0; + const own = { ...rows[0], managerId: 'mine' }, hidden = { ...rows[1], managerId: 'secret', code: 'PRIVATE' }; + const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [own, hidden] }; }); + assert.equal((await router.request('/trend')).status, 401); + assert.equal((await router.request('/trend-analysis')).status, 401); + const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router); + assert.equal((await app.request('/faults/trend?start=bad')).status, 400); assert.equal(calls, 0); + const params = '?start=2026-09-30&end=2026-10-03&granularity=month'; + const response = await app.request(`/faults/trend${params}`); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + const data = await response.json(); assert.equal(data.totals.reported, 1); + const detail = await (await app.request(`/faults/trend-analysis${params}&period=2026-09&status=closed`)).json(); + assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE')); +}); diff --git a/src/server/routes/asset-faults/trend.ts b/src/server/routes/asset-faults/trend.ts new file mode 100644 index 0000000..f112fc5 --- /dev/null +++ b/src/server/routes/asset-faults/trend.ts @@ -0,0 +1,42 @@ +import type { FaultTrendQuery, FaultTrendResponse } from '../../../shared/assets/faults.js'; +import { faultAnalysis, parseFaultQuery, type ScopedFault } from './model.js'; + +export function parseFaultTrendQuery(params: Record): FaultTrendQuery { + const defaults = parseFaultQuery({}); + const start = params.start || defaults.start, end = params.end || defaults.end; + const valid = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; + if (!valid(start) || !valid(end) || start > end || Date.parse(end) - Date.parse(start) > 369 * 86400000) throw new Error('请选择有效日期范围,最多370天'); + const granularity = params.granularity || 'day'; + if (granularity !== 'day' && granularity !== 'month') throw new Error('无效时间粒度'); + const status = params.status || 'all'; + if (!['all', 'closed', 'unclosed'].includes(status)) throw new Error('无效闭环状态'); + const length = granularity === 'day' ? 10 : 7; + if (params.period && (params.period.length !== length || params.period < start.slice(0, length) || params.period > end.slice(0, length) + || !(granularity === 'day' ? valid(params.period) : valid(`${params.period}-01`)))) throw new Error('统计周期超出所选范围'); + return { start, end, granularity, status: status as FaultTrendQuery['status'], period: params.period }; +} +export function selectFaultCohort(rows: ScopedFault[], query: FaultTrendQuery) { + return rows.filter(row => { + const day = row.reportedAt?.slice(0, 10); + return day && day >= query.start && day <= query.end + && (!query.period || day.slice(0, query.granularity === 'day' ? 10 : 7) === query.period) + && (query.status === 'all' || (row.status === '已归档') === (query.status === 'closed')); + }); +} +export function faultTrend(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string): FaultTrendResponse { + const periods = new Map(); + for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) { + const period = new Date(time).toISOString().slice(0, query.granularity === 'day' ? 10 : 7); + if (!periods.has(period)) periods.set(period, { period, reported: 0, closed: 0, unclosed: 0 }); + } + const totals = { reported: 0, closed: 0, unclosed: 0 }; + for (const row of selectFaultCohort(rows, { ...query, status: 'all', period: undefined })) { + const period = periods.get(row.reportedAt!.slice(0, query.granularity === 'day' ? 10 : 7))!; + const status = row.status === '已归档' ? 'closed' : 'unclosed'; + period.reported++; period[status]++; totals.reported++; totals[status]++; + } + return { start: query.start, end: query.end, granularity: query.granularity, updatedAt, totals, periods: [...periods.values()] }; +} +export function faultTrendAnalysis(rows: ScopedFault[], query: FaultTrendQuery, updatedAt: string) { + return faultAnalysis(selectFaultCohort(rows, query), 'ledger', updatedAt); +} diff --git a/src/server/routes/asset-faults/weekly-report.test.ts b/src/server/routes/asset-faults/weekly-report.test.ts new file mode 100644 index 0000000..1303311 --- /dev/null +++ b/src/server/routes/asset-faults/weekly-report.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { mapFault, type ScopedFault } from './model.js'; +import { faultWeeklyReport, faultWeeklyAnalysis, parseFaultWeeklyQuery } from './weekly-report.js'; +import { createFaultRouter } from './index.js'; +import type { AuthUser } from '../../auth/types.js'; +const at = '2026-10-13T08:00:00Z'; +const query = (params: Record = {}) => parseFaultWeeklyQuery({ start: '2026-10-10', end: '2026-10-16', ...params }); +const fault = (id: string, fields: Record = {}): ScopedFault => mapFault({ id, matched_vehicle_id: 'v1', ...fields }, new Map()); +const rows = [fault('old', { reported_at: '2026-09-01 08:00:00', received_at: '2026-10-10 00:00:00', archived_at: '2026-10-13 23:59:59', handling_status: '已归档' }), + fault('new', { reported_at: '2026-10-10 23:59:59', received_at: '2026-10-11 00:00:00', handling_status: '挂起' }), + fault('missing', { handling_status: '处理中' }), + fault('before', { received_at: '2026-10-09 23:59:59' }), + fault('after', { reported_at: '2026-10-17 00:00:00', received_at: '2026-10-17 00:00:00' }), + fault('not-archived', { archived_at: '2026-10-12 08:00:00', handling_status: '处理中' })]; + +test('周报按各自事件日期独立统计,历史提报可本周接收与闭环,缺事件时间不推断', () => { + const result = faultWeeklyReport(rows, query(), at); + assert.deepEqual(result.totals, { reported: 1, received: 2, closed: 1 }); + assert.equal(result.days.length, 7); + assert.deepEqual(result.days[0], { date: '2026-10-10', reported: 1, received: 1, closed: 0 }); + assert.equal(result.days[1].received, 1); assert.equal(result.days[3].closed, 1); + assert.deepEqual(result.days[6], { date: '2026-10-16', reported: 0, received: 0, closed: 0 }); + for (const metric of ['reported', 'received', 'closed'] as const) { + assert.equal(result.totals[metric], result.days.reduce((n, day) => n + day[metric], 0)); + assert.equal(faultWeeklyAnalysis(rows, query({ metric }), at).rows.length, result.totals[metric]); + for (const day of result.days) assert.equal(faultWeeklyAnalysis(rows, query({ metric, day: day.date }), at).rows.length, day[metric]); + } + assert.equal(faultWeeklyAnalysis(rows, query({ metric: 'received', day: '2026-10-11' }), at).rows[0].receivedAt, '2026-10-11 00:00:00'); +}); +test('周报时间与指标校验,自选单日和无数据日期补零', () => { + const invalid: Record[] = [{ metric: '__proto__' }, { start: 'bad' }, { end: '2026-01-01' }, { day: '2026-10-17' }, { day: '2026-10' }]; + for (const params of invalid) assert.throws(() => query(params)); + const single = faultWeeklyReport([], query({ start: '2026-10-13', end: '2026-10-13' }), at); + assert.deepEqual(single.days, [{ date: '2026-10-13', reported: 0, received: 0, closed: 0 }]); +}); +test('周报汇总和接收处理下钻先过滤权限,未认证与无效参数不查询', async () => { + let calls = 0; + const router = createFaultRouter(async () => { calls++; return { updatedAt: at, rows: [{ ...rows[0], managerId: 'mine' }, { ...rows[1], managerId: 'other', code: 'PRIVATE' }] }; }); + for (const path of ['/weekly-report', '/weekly-report-analysis']) assert.equal((await router.request(path)).status, 401); + const user: AuthUser = { userId: 'mine', userName: 'User', loginName: 'user', depCode: '', depName: '', permissionLevel: 'personal', roles: [] }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); app.route('/faults', router); + assert.equal((await app.request('/faults/weekly-report?start=bad')).status, 400); assert.equal(calls, 0); + const params = '?start=2026-10-10&end=2026-10-16&metric=received'; + const response = await app.request(`/faults/weekly-report${params}`); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + const data = await response.json(); assert.equal(data.totals.received, 1); + const detail = await (await app.request(`/faults/weekly-report-analysis${params}`)).json(); + assert.equal(detail.rows.length, 1); assert.ok(!JSON.stringify(detail).includes('PRIVATE')); +}); diff --git a/src/server/routes/asset-faults/weekly-report.ts b/src/server/routes/asset-faults/weekly-report.ts new file mode 100644 index 0000000..7237469 --- /dev/null +++ b/src/server/routes/asset-faults/weekly-report.ts @@ -0,0 +1,31 @@ +import { FAULT_WEEKLY_METRICS, type FaultWeeklyQuery, type FaultWeeklyResponse } from '../../../shared/assets/faults.js'; +import { faultAnalysis, type ScopedFault } from './model.js'; +import { parseFaultTrendQuery } from './trend.js'; + +export function parseFaultWeeklyQuery(params: Record): FaultWeeklyQuery { + const range = parseFaultTrendQuery({ start: params.start, end: params.end, granularity: 'day', period: params.day }); + const metric = params.metric || 'reported'; + if (!Object.hasOwn(FAULT_WEEKLY_METRICS, metric)) throw new Error('无效周报指标'); + return { start: range.start, end: range.end, day: range.period, metric: metric as FaultWeeklyQuery['metric'] }; +} +function eventDay(row: ScopedFault, metric: FaultWeeklyQuery['metric']) { + return (metric === 'reported' ? row.reportedAt : metric === 'received' ? row.receivedAt : row.status === '已归档' ? row.archivedAt : null)?.slice(0, 10); +} +export function faultWeeklyReport(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string): FaultWeeklyResponse { + const days = new Map(); + for (let time = Date.parse(query.start); time <= Date.parse(query.end); time += 86400000) { + const date = new Date(time).toISOString().slice(0, 10); days.set(date, { date, reported: 0, received: 0, closed: 0 }); + } + const totals = { reported: 0, received: 0, closed: 0 }; + for (const row of rows) for (const metric of Object.keys(FAULT_WEEKLY_METRICS) as FaultWeeklyQuery['metric'][]) { + const day = days.get(eventDay(row, metric) || ''); + if (day) { day[metric]++; totals[metric]++; } + } + return { start: query.start, end: query.end, updatedAt, totals, days: [...days.values()] }; +} +export function faultWeeklyAnalysis(rows: ScopedFault[], query: FaultWeeklyQuery, updatedAt: string) { + return faultAnalysis(rows.filter(row => { + const date = eventDay(row, query.metric); + return date && date >= query.start && date <= query.end && (!query.day || date === query.day); + }), 'ledger', updatedAt); +} diff --git a/src/shared/assets/faults.ts b/src/shared/assets/faults.ts new file mode 100644 index 0000000..4af5a44 --- /dev/null +++ b/src/shared/assets/faults.ts @@ -0,0 +1,65 @@ +import type { AssetAnalysisCell } from './analytics'; + +export const FAULT_DIMENSIONS = { + city: '运营城市', status: '故障状态', inventory: '库存状态', + customer: '客户名称', department: '业务部门', handler: '运维处置人', +} as const; +export type FaultDimension = keyof typeof FAULT_DIMENSIONS; +export type FaultMetric = 'reported' | 'closed' | 'open'; +export interface FaultOption { value: string; label: string } +export interface FaultCounts { reported: number; closed: number; open: number } +export interface FaultRecord { + id: string; code: string; plate: string; description: string; + reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null; + city: string; status: string; inventory: string; + customer: string; customerLabel: string; department: string; + handlers: FaultOption[]; +} +export interface FaultQuery { + start: string; end: string; groupBy: FaultDimension; + filters: Partial>; + metric: FaultMetric; day?: string; groupValue?: string; page: number; + subject?: string; +} +export interface FaultResponse { + start: string; end: string; updatedAt: string; + totals: FaultCounts; + daily: { date: string; reported: number; closed: number }[]; + groups: (FaultOption & FaultCounts)[]; + options: Record; + quality: { missingReport: number; invalidChronology: number; missingVehicle: number }; + details: { rows: FaultRecord[]; total: number; page: number; pageSize: number }; +} + +export const FAULT_OVERVIEW_METRICS = { reported: '今日新增', closed: '本日解决', open: '当前在办', waiting: '待处理', processing: '处理中', suspended: '挂起', overdue: '逾期', ledger: '累计台账' } as const; +export type FaultOverviewMetric = keyof typeof FAULT_OVERVIEW_METRICS; +export interface FaultOverview { + date: string; updatedAt: string; + totals: Record; +} +export interface FaultAnalysisRecord extends FaultRecord { cell: AssetAnalysisCell } +export interface FaultAnalysisResponse { + date: string; updatedAt: string; metric: FaultOverviewMetric; rows: FaultAnalysisRecord[]; + customerLabels: Record; +} + +export type FaultTrendStatus = 'all' | 'closed' | 'unclosed'; +export interface FaultTrendQuery { + start: string; end: string; granularity: 'day' | 'month'; + status: FaultTrendStatus; period?: string; +} +export interface FaultTrendCounts { reported: number; closed: number; unclosed: number } +export interface FaultTrendResponse { + start: string; end: string; granularity: 'day' | 'month'; updatedAt: string; + totals: FaultTrendCounts; + periods: (FaultTrendCounts & { period: string })[]; +} + +export const FAULT_WEEKLY_METRICS = { reported: '新增故障', received: '接收处理数量', closed: '解决故障' } as const; +export type FaultWeeklyMetric = keyof typeof FAULT_WEEKLY_METRICS; +export interface FaultWeeklyQuery { start: string; end: string; metric: FaultWeeklyMetric; day?: string } +export interface FaultWeeklyResponse { + start: string; end: string; updatedAt: string; + totals: Record; + days: (Record & { date: string })[]; +} diff --git a/vite.config.ts b/vite.config.ts index ae06d0d..f65faf0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, plugins: [react(), tailwindcss()], server: { + host: '0.0.0.0', + allowedHosts: ['qunfa.lnoneos.com'], // Preview requests append audit logs; watching them creates a reload/request loop. watch: { ignored: ['**/logs/**'] }, proxy: { From 4dec824285451ac735840aae0f623fb45f60e709 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Tue, 13 Oct 2026 16:24:28 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(assets):=20=E4=BC=98=E5=8C=96=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=E5=88=86=E6=9E=90=E4=BD=93=E9=AA=8C=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=20AI=20=E6=8F=90=E9=97=AE=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/asset-ai-question-log.md | 23 +++ migrations/20260928_asset_ai_questions.sql | 31 ++++ scripts/migrate-ai-questions.ts | 16 ++ .../assets/assistant/AssetAssistant.tsx | 18 +- .../assets/assistant/CompoundResultView.tsx | 11 +- .../assets/assistant/GenerativeResultView.tsx | 82 ++++++--- .../assets/assistant/ResultActions.tsx | 8 + src/modules/assets/assistant/ResultView.tsx | 35 ++-- .../assets/assistant/ThinkingState.tsx | 7 +- .../assets/assistant/mobile-assistant.css | 122 +++++++++++++ .../assistant/mobile-presentation.test.ts | 36 ++++ .../assets/assistant/mobile-presentation.ts | 26 +++ .../components/AbnormalVehicleDetails.tsx | 2 +- .../assets/components/FlowAnalysisModal.tsx | 41 +++-- .../components/analysis/AnalysisControls.tsx | 13 +- .../analysis/AssetAnalysisModal.tsx | 71 ++++++-- .../components/analysis/useProgressiveRows.ts | 14 +- src/server/auth/preview/routes.ts | 2 +- .../routes/asset-assistant/analysis.test.ts | 7 + .../asset-assistant/boss-experience.test.ts | 7 + .../asset-assistant/composition.test.ts | 7 + .../routes/asset-assistant/experience.test.ts | 7 + .../asset-assistant/generative-route.test.ts | 7 + src/server/routes/asset-assistant/index.ts | 28 ++- .../routes/asset-assistant/model.test.ts | 7 + .../asset-assistant/question-log.test.ts | 160 ++++++++++++++++++ .../routes/asset-assistant/question-log.ts | 72 ++++++++ .../vehicle-answer.acceptance.test.ts | 7 + 28 files changed, 781 insertions(+), 86 deletions(-) create mode 100644 docs/asset-ai-question-log.md create mode 100644 migrations/20260928_asset_ai_questions.sql create mode 100644 scripts/migrate-ai-questions.ts create mode 100644 src/modules/assets/assistant/ResultActions.tsx create mode 100644 src/modules/assets/assistant/mobile-assistant.css create mode 100644 src/modules/assets/assistant/mobile-presentation.test.ts create mode 100644 src/modules/assets/assistant/mobile-presentation.ts create mode 100644 src/server/routes/asset-assistant/question-log.test.ts create mode 100644 src/server/routes/asset-assistant/question-log.ts diff --git a/docs/asset-ai-question-log.md b/docs/asset-ai-question-log.md new file mode 100644 index 0000000..edfa371 --- /dev/null +++ b/docs/asset-ai-question-log.md @@ -0,0 +1,23 @@ +# AI 问数全量记录 + +在部署包含本次代码的服务之前,执行增量建表(只创建审计表,不修改业务数据): + +```sh +node --import tsx scripts/migrate-ai-questions.ts # 只查看 SQL,不连接数据库 +node --import tsx scripts/migrate-ai-questions.ts --apply # 使用部署环境 DB 配置执行 +``` + +表:`bi_asset_ai_questions`。所有已登录、通过参数校验的 `/ask` 请求都先 INSERT,再执行查询;不按问题去重。 +未登录、空问题、非法请求体不作为用户提问保存。输入中的凭据会脱敏。没有自动清理策略;旧的需求缺口表仍按原有 90 天规则维护。 + +- `event_type`:question 首次提问、followup 连续追问、action 下钻操作、feedback 未解决反馈。 +- `business_types`:JSON 数组,使用既有计划分类(inventory 库存、operating 运营、pending 待交车、abnormal 异动、flow 交还替换等);综合问题可以有多个分类,无法完成理解时为空数组。 +- `outcome`:processing 处理中、success 成功、empty 无匹配、partial 部分回答、clarification 待澄清、help 使用说明、unsupported 暂不支持、failed 失败、cancelled 取消、rejected 拒绝执行、feedback 用户反馈。 +- `reason`:失败/拒绝/缺口原因代码,例如 busy、session_expired、generation、unsatisfied。 +- `actor_id` 是实际操作者;`user_id`、`login_name`、`user_name` 是当前数据权限用户;模拟视角另有 `is_preview` 与 `preview_session_id`。 +- `question` 保存接受的完整问题(最多 2000 字符,敏感凭据脱敏),`action_json` 保存下钻参数。会话、上级结果、当前结果、公司和权限级别分别关联。 +- `answer` 保存回答摘要、提示和子分析摘要,不复制车辆明细数据;另存引擎、开始/结束时间、毫秒耗时。 + +初始写入失败返回 503,不继续执行无记录的查询。结束更新失败重试一次;原始问题仍在数据库,回答提示“处理结果暂未保存”,服务只输出记录 ID,不输出问题或数据库错误。进程突然终止或结束更新失败的记录保持 processing,可通过 started_at 识别长期未完成,不能将其算作成功。 + +模拟用户提问同样记录,但不新增需求缺口反馈,也不改变原有只读业务权限。没有新增公开查询接口;数据库查看权限沿用现有管理制度。新表不能补回此前仅在内存中保存的历史问题。 diff --git a/migrations/20260928_asset_ai_questions.sql b/migrations/20260928_asset_ai_questions.sql new file mode 100644 index 0000000..5bf9d89 --- /dev/null +++ b/migrations/20260928_asset_ai_questions.sql @@ -0,0 +1,31 @@ +-- Explicit additive migration. No business tables are changed and no historical records are purged. +CREATE TABLE IF NOT EXISTS bi_asset_ai_questions ( + id CHAR(36) PRIMARY KEY, + actor_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + login_name VARCHAR(255) NOT NULL, + user_name VARCHAR(255) NOT NULL, + permission_level VARCHAR(24) NOT NULL, + is_preview TINYINT(1) NOT NULL DEFAULT 0, + preview_session_id VARCHAR(64) NULL, + session_id VARCHAR(64) NULL, + base_result_id VARCHAR(64) NULL, + result_id VARCHAR(64) NULL, + subject VARCHAR(150) NULL, + event_type VARCHAR(24) NOT NULL, + question TEXT NULL, + action_json JSON NULL, + outcome VARCHAR(24) NOT NULL, + reason VARCHAR(64) NULL, + business_types JSON NULL, + engine VARCHAR(24) NULL, + answer MEDIUMTEXT NULL, + started_at DATETIME(3) NOT NULL, + finished_at DATETIME(3) NULL, + duration_ms BIGINT UNSIGNED NULL, + KEY user_time (user_id, started_at), + KEY actor_time (actor_id, started_at), + KEY result_time (outcome, started_at), + KEY session_time (session_id, started_at), + KEY event_time (event_type, started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/scripts/migrate-ai-questions.ts b/scripts/migrate-ai-questions.ts new file mode 100644 index 0000000..f12bff3 --- /dev/null +++ b/scripts/migrate-ai-questions.ts @@ -0,0 +1,16 @@ +import { readFile } from 'node:fs/promises'; +// Import the pool only after an explicit apply flag, to avoid accidental connections in review mode. +const sql = await readFile(new URL('../migrations/20260928_asset_ai_questions.sql', import.meta.url), 'utf8'); +if (!process.argv.includes('--apply')) { + console.log(sql); + console.log('Review only. To create this audit table in the configured DB, pass --apply.'); +} else { + const { default: pool } = await import('../src/server/db.js'); + try { + await pool.query(sql); + const [rows] = await pool.query('SELECT COUNT(*) AS columns_count FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=?', ['bi_asset_ai_questions']); + console.log('AI question audit table ready.', rows); + } finally { + await pool.end(); + } +} diff --git a/src/modules/assets/assistant/AssetAssistant.tsx b/src/modules/assets/assistant/AssetAssistant.tsx index 9d1783e..c7ecf27 100644 --- a/src/modules/assets/assistant/AssetAssistant.tsx +++ b/src/modules/assets/assistant/AssetAssistant.tsx @@ -34,7 +34,9 @@ import { useAuth } from "../../../auth/useAuth"; import { ThinkingState } from "./ThinkingState"; import { CompoundResultView } from "./CompoundResultView"; import { BUSINESS_QUESTIONS, conversationTitle, findConversationResult, replaceAnalysisResult } from "./conversation-presentation"; +import { useMobileLayout } from "../useMobileLayout"; import "./assistant.css"; +import "./mobile-assistant.css"; type Turn = { id: number; @@ -55,24 +57,28 @@ const PROMPTS = [ { icon: BarChart3, title: "现在多少车在运营、多少车在库存?", + mobileTitle: "运营与库存", text: BUSINESS_QUESTIONS[0], detail: "总资产 · 运营 · 库存 · 运营率", }, { icon: Truck, title: "库存集中在哪些车型和区域?", + mobileTitle: "库存分布", text: BUSINESS_QUESTIONS[1], detail: "库存分布 · 继续查看车辆", }, { icon: History, title: "最近7天交车、还车分别多少?", + mobileTitle: "近 7 天交还车", text: BUSINESS_QUESTIONS[2], detail: "按日趋势 · 业务明细", }, { icon: ListIcon, title: "有哪些车辆需要跟进?", + mobileTitle: "待跟进车辆", text: BUSINESS_QUESTIONS[3], detail: "异动详情 · 预计完成时间", }, @@ -94,6 +100,8 @@ export default function AssetAssistant({ const dataScopeLabel = !user ? "正在确认数据范围" : user.permissionLevel === "full" ? "全量数据" : user.permissionLevel === "department" ? "部门数据" : "本人数据"; + const mobile = useMobileLayout(); + const [contextExpanded, setContextExpanded] = useState(false); const page = useRef(null); useLayoutEffect(() => { const viewport = window.visualViewport; @@ -557,7 +565,7 @@ export default function AssetAssistant({
- {p.title} + {mobile ? p.mobileTitle : p.title} {p.detail} ))} @@ -745,7 +753,7 @@ export default function AssetAssistant({ )}
- {turns.length > 0 && ( + {turns.length > 0 && (!mobile || (viewedTurn !== undefined && viewedTurn !== turns.at(-1)?.id)) && ( )} {lastPlan && ( -
- 正在分析 +
{ if (mobile) setContextExpanded(event.currentTarget.open); }}>追问范围:{lastPlan.kind === 'flow' ? '交还车' : CATEGORY_LABELS[lastPlan.category]}{Object.keys(lastPlan.filters).length ? ` · ${Object.entries(lastPlan.filters).map(([dimension,values]) => `${GROUP_LABELS[dimension as Dimension]} ${values.length === 1 ? values[0] : `${values.length}项`}`).join(' · ')}` : ''}
+ 当前条件 {lastPlan.kind === "flow" ? "交退换车" @@ -847,7 +855,7 @@ export default function AssetAssistant({ 换个话题 -
+
)} {independent && (

diff --git a/src/modules/assets/assistant/CompoundResultView.tsx b/src/modules/assets/assistant/CompoundResultView.tsx index 42a39c3..3e9c5ff 100644 --- a/src/modules/assets/assistant/CompoundResultView.tsx +++ b/src/modules/assets/assistant/CompoundResultView.tsx @@ -6,6 +6,8 @@ import type { } from "../../../shared/assets/assistant"; import { ResultView, type ResultViewProps } from "./ResultView"; import { GenerativeResultView } from "./GenerativeResultView"; +import { useMobileLayout } from '../useMobileLayout'; +import { mobileResultSummary } from './mobile-presentation'; import { copyResultText } from "./result-presentation"; export type CompoundResultViewProps = Omit< @@ -34,6 +36,7 @@ export function CompoundResultView({ activate, ...props }: CompoundResultViewProps) { + const mobile = useMobileLayout(); const [selection, setSelection] = useState<{ sectionId: string; observedActiveId?: string; @@ -72,7 +75,7 @@ export function CompoundResultView({ externallyActive || sections[0]; const selectedIndex = sections.indexOf(selected); - const stacked = result.layout === "stack"; + const stacked = result.layout === "stack" && !mobile; const renderSection = (section: typeof selected) => { const child = section.result; const correction = child?.resultId @@ -120,11 +123,11 @@ export function CompoundResultView({ className="aq-compound-heading" id={result.resultId ? `aq-query-${result.resultId}` : undefined} > -

+
{result.title || "综合分析"} {sections.length}项分析
-

+

{result.summary}

@@ -219,7 +222,7 @@ export function CompoundResultView({ {section.title} - {section.result?.summary || section.error || "本项暂无结果"} + {section.result ? (mobile ? mobileResultSummary(section.result) : section.result.summary) : section.error || "本项暂无结果"} {chosen ? : } diff --git a/src/modules/assets/assistant/GenerativeResultView.tsx b/src/modules/assets/assistant/GenerativeResultView.tsx index 109e6ba..0cf70c2 100644 --- a/src/modules/assets/assistant/GenerativeResultView.tsx +++ b/src/modules/assets/assistant/GenerativeResultView.tsx @@ -45,6 +45,10 @@ import { type PresentationDatum, } from "./generative-presentation"; +import { useMobileLayout } from '../useMobileLayout'; +import { mobileBlockGroups, mobileResultSummary } from './mobile-presentation'; +import { ResultActions } from './ResultActions'; + const COLORS = [ "#4e806a", "#527da5", @@ -69,6 +73,7 @@ function DataTable({ operate, disabled, }: DataBlockProps) { + const mobile = useMobileLayout(); const [page, setPage] = useState(0); const data = presentationData(result, block.dataset); const columns = presentationFields(block); @@ -78,7 +83,16 @@ function DataTable({ ); return ( <> -
+ {mobile ?
{data.slice(currentPage * 8, currentPage * 8 + 8).map((row, index) => { + const action = presentationAction(interaction, row); + const vehicle = block.dataset === 'rows' && columns.some(column => column.field === 'plate'); + const primary = vehicle ? columns.filter(column => ['plate', 'status', 'event', 'vehicleClass', 'type', 'city'].includes(column.field)) : columns.slice(0, 3); + const extra = columns.filter(column => !primary.includes(column)); + return
+ {vehicle ? <>
{presentationValue(row.plate, 'plate')}{primary.filter(column => ['status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}

{primary.filter(column => !['plate', 'status', 'event'].includes(column.field)).map(column => presentationValue(row[column.field], column.field)).join(' · ')}

:
{primary.map(column =>
{column.label}{presentationValue(row[column.field], column.field)}
)}
} + {(extra.length > 0 || action) &&
{extra.length > 0 &&
更多字段
{extra.map(column =>
{column.label}
{presentationValue(row[column.field], column.field)}
)}
}{action && }
} +
; + })}
:
@@ -132,7 +146,7 @@ function DataTable({ })}
{block.title || "分析数据"}
-
+
} {data.length > 8 && (
@@ -159,6 +173,7 @@ function DataTable({ ); } function DataChart(props: DataBlockProps) { + const mobile = useMobileLayout(); const { block, result, interaction, operate, disabled } = props; const [expanded, setExpanded] = useState(false); const allData = presentationData(result, block.dataset); @@ -186,6 +201,15 @@ function DataChart(props: DataBlockProps) { }; if (!series.length) return ; const common = { data, margin: { top: 8, right: 16, bottom: 0, left: 0 } }; + if (mobile && (block.chart === 'bar' || (block.chart === 'donut' && block.dataset === 'groups')) && series.length === 1 && allData.every(row => typeof row[series[0].field] === 'number' && Number(row[series[0].field]) >= 0)) { + const field = series[0].field; + const max = Math.max(1, ...allData.map(row => typeof row[field] === 'number' ? Math.abs(row[field] as number) : 0)); + return
{(expanded ? allData : allData.slice(0, 5)).map((row, index) => { + const action = presentationAction(interaction, row); + const content = <>{datumName(row)}{presentationValue(row[field], field)}{action && }; + return action ? :
{content}
; + })}{allData.length > 5 && }
; + } return ( <>
)} -
+ {!mobile &&
查看图表数据 -
+
} ); } @@ -319,6 +343,9 @@ function Block({ operate, disabled, }: Omit) { + const mobile = useMobileLayout(); + const [expandedMetrics, setExpandedMetrics] = useState(false); + const metrics = presentationMetrics(result, block.metrics); const interaction = result.presentation?.interactions.find( (item) => item.id === block.interactionId, ); @@ -345,8 +372,8 @@ function Block({ ) : block.kind === "narrative" ? (

{block.text}

) : block.kind === "metrics" ? ( -
- {presentationMetrics(result, block.metrics).map((metric) => ( + <>
+ {(mobile && !expandedMetrics ? metrics.slice(0, 4) : metrics).map((metric) => (
{metric.label} @@ -355,10 +382,10 @@ function Block({
))} -
+
{mobile && metrics.length > 4 && } ) : ( <> - {interaction && ( + {interaction && !mobile && (

点击{block.dataset === "daily" ? "日期" : "数据项"} →{" "} {interaction.label} @@ -391,6 +418,12 @@ function Block({ ); } +function MobileDataBlock({ block, table, ...props }: Omit & { table?: PresentationBlock }) { + const [view, setView] = useState<'chart' | 'table'>('chart'); + const tableBlock: PresentationBlock = table || { ...block, kind: 'table', columns: block.columns || [{ field: block.dataset === 'daily' ? 'date' : block.dataset === 'comparison' ? 'label' : 'name', label: block.dataset === 'daily' ? '日期' : '分组' }, ...presentationSeries(block)] }; + return

{block.title || '数据分析'}

; +} + function GenerativeResultViewContent({ result, operate, @@ -403,6 +436,7 @@ function GenerativeResultViewContent({ correctionResultId, showCorrection, }: ResultViewProps) { + const mobile = useMobileLayout(); const [noticeByResult, setNoticeByResult] = useState<{ key: string; text: string; @@ -536,7 +570,7 @@ function GenerativeResultViewContent({ 已纠正上一条回答:{result.correction.message}

)} - {presentation.title && ( + {!mobile && presentation.title && (

{presentation.title}

)} {result.outcome === "empty" && ( @@ -552,15 +586,11 @@ function GenerativeResultViewContent({ {result.outcome === "unsupported" && (

本次需求暂不能完整生成。

)} - {presentation.blocks.map((block) => ( - - ))} + {mobile ? <> +

{mobileResultSummary(result)}

+ {mobileBlockGroups(presentation.blocks).filter(({ block }) => block.kind !== 'summary' && block.kind !== 'narrative').map(({ block, table }) => block.kind === 'chart' ? : )} +
分析说明

{result.summary}

{presentation.blocks.filter(block => (block.kind === 'summary' || block.kind === 'narrative') && block.text && block.text !== result.summary).map(block =>

{block.text}

)}
+ : presentation.blocks.map((block) => )} {!!result.unanswered?.length && (
尚未回答 @@ -611,7 +641,12 @@ function GenerativeResultViewContent({ {result.contextNote &&

{result.contextNote}

}

来源:{result.source}

-
+ + {mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")} + }> - + -
+ {notice && (

{notice} diff --git a/src/modules/assets/assistant/ResultActions.tsx b/src/modules/assets/assistant/ResultActions.tsx new file mode 100644 index 0000000..b2f3bce --- /dev/null +++ b/src/modules/assets/assistant/ResultActions.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from 'react'; +import { MoreHorizontal } from 'lucide-react'; +import { useMobileLayout } from '../useMobileLayout'; + +export function ResultActions({ children, primary }: { children: ReactNode; primary?: ReactNode }) { + const mobile = useMobileLayout(); + return mobile ?

{primary}
更多
{children}
:
{primary}{children}
; +} diff --git a/src/modules/assets/assistant/ResultView.tsx b/src/modules/assets/assistant/ResultView.tsx index dc266f0..000948e 100644 --- a/src/modules/assets/assistant/ResultView.tsx +++ b/src/modules/assets/assistant/ResultView.tsx @@ -26,7 +26,12 @@ import { compactAnalysisPath, } from "./result-presentation"; +import { useMobileLayout } from '../useMobileLayout'; +import { mobileResultSummary } from './mobile-presentation'; +import { ResultActions } from './ResultActions'; + function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { + const mobile = useMobileLayout(); const [page, setPage] = useState(0), [search, setSearch] = useState(""); const filtered = rows.filter((r) => @@ -52,7 +57,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { {filtered.length}条
-
+ {mobile ?
{filtered.slice(current * 8, current * 8 + 8).map(r =>
{r.plate || '未登记车牌'}{flow ? r.event : r.status}

{r.type} · {r.city || '未标注城市'}

更多资料
{[['型号', r.model], ['部门', r.department], ...(flow ? [['日期', r.date]] : [['库存子集', r.abnormal ? '异动' : r.pending ? '待交车' : '—'], ['异动类型', r.abnormalType], ['预计完成时间', r.targetCompletionTime]])].map(([label,value]) =>
{label}
{value || '—'}
)}
)}
:
@@ -90,7 +95,7 @@ function Records({ rows, flow }: { rows: AssistantRow[]; flow: boolean }) { ))}
-
+
} {!filtered.length &&

没有符合条件的明细

}
@@ -148,6 +153,7 @@ function ResultViewContent({ showCorrection, display: preferredDisplay, }: ResultViewProps) { + const mobile = useMobileLayout(); const [expandedGroupResult, setExpandedGroupResult] = useState( null, ), @@ -360,7 +366,7 @@ function ResultViewContent({ className="aq-answer" id={result.resultId ? `aq-answer-${result.resultId}` : undefined} > - {result.summary} + {mobile ? mobileResultSummary(result) : result.summary}

{!result.conciseCount && !!visibleMetrics.length && (
@@ -790,6 +796,8 @@ function ResultViewContent({ 点击分组继续下钻 · 筛选条件自动用于下一次追问

+ ) : mobile ? ( +
{result.groups.map(g =>
分组{g.name}
车辆数{g.count}
占比{g.share == null ? '—' : `${g.share}%`}
{plan.category !== 'operating' &&
更多指标

运营率 {g.rate}% · 异动 {g.abnormal ?? '—'} · 待交车 {g.pending ?? '—'}

}
)}
) : (
@@ -859,6 +867,7 @@ function ResultViewContent({ 统计口径、权限与数据时间{result.warning ? " · 有补充说明" : ""} + {mobile &&

{result.summary}

}

公司:{scope.company} · 权限:{scope.permission}

@@ -877,7 +886,14 @@ function ResultViewContent({ ))}

来源:{result.source}

-
+ + {mobile ? (active ? "当前追问范围" : "继续追问") : (active ? "正在基于此结果追问" : "基于此结果追问")} + + )}> {plan.kind !== "clarify" && (!result.conciseCount || result.metrics[0]?.value > 0) && ( - )} + @@ -910,7 +919,7 @@ function ResultViewContent({ 复制完整结果 -
+ {notice && (

{notice} diff --git a/src/modules/assets/assistant/ThinkingState.tsx b/src/modules/assets/assistant/ThinkingState.tsx index bb7e1ef..7644211 100644 --- a/src/modules/assets/assistant/ThinkingState.tsx +++ b/src/modules/assets/assistant/ThinkingState.tsx @@ -3,6 +3,8 @@ import { useEffect, useId, useState } from "react"; import { Check, ChevronDown, CircleAlert, LoaderCircle, Pause, Sparkles } from "lucide-react"; +import { useMobileLayout } from '../useMobileLayout'; + export function ThinkingState({ steps, working, @@ -18,6 +20,7 @@ export function ThinkingState({ stopped?: boolean; outcome?: string; }) { + const mobile = useMobileLayout(); const traceId = useId(); const [manualExpanded, setExpanded] = useState(null); const [elapsed, setElapsed] = useState(0); @@ -28,7 +31,7 @@ export function ThinkingState({ const timer = window.setInterval(update, 1000); return () => window.clearInterval(timer); }, [working, started]); - const expanded = manualExpanded ?? working; + const expanded = manualExpanded ?? (working && !mobile); const StatusIcon = working ? LoaderCircle : stopped ? Pause : failed ? CircleAlert : Sparkles; return (

@@ -41,7 +44,7 @@ export function ThinkingState({
{dimensions.length ? dimensions.map(dimension => ) : } {shownGroups.map((group, rowIndex) => 0 ? 'bi-flow-modal-group-start' : undefined}> {dimensions.length ? group.values.map((value, index) => { @@ -159,16 +172,18 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo return span === 0 ? null : ; }) : } - + )} -
{DIMENSION_LABELS[dimension]}范围车次占比操作
1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <>合计 {countLabel(parentTotals[rowIndex][index])} 车次{DIMENSION_LABELS[dimensions[index + 1]]}:{countLabel(childCounts[rowIndex][index])} {dimensionCountUnit(dimensions[index + 1])}}当前筛选范围{countLabel(group.rows.length)}
{(group.rows.length / filteredRows.length * 100).toFixed(1)}%
+
} - :
当前筛选范围暂无交还车数据,请调整筛选。
+ :
{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}
: detailRows.length ? <> -
{shownDetails.map(item => )}
车牌业务类型运维签章时间建单时间部门业务负责人客户合同项目名称
{item.plateNumber || '未登记车牌'}{FLOW_META[item.type].label}{item.eventTime || '—'}{item.submitTime || '—'}{dimensionLabel('department', dimensionValue(item, 'department'))}{item.manager || '—'}{item.customerName || '—'}{item.contractNo || '—'}{item.projectName || '—'}
+ {mobile ?
{shownDetails.map(item =>

{item.plateNumber || '未登记车牌'}

{FLOW_META[item.type].label}

{item.eventTime || item.date} · {item.department || '未填写部门'}

{item.customerName || '未填写客户'}

业务资料
{[['运维签章时间', item.eventTime], ['建单时间', item.submitTime], ['业务负责人', item.manager], ['合同', item.contractNo], ['项目名称', item.projectName]].map(([label,value]) =>
{label}
{value || '未提供'}
)}
)}
: +
{shownDetails.map(item => )}
车牌业务类型运维签章时间建单时间部门业务负责人客户合同项目名称
{item.plateNumber || '未登记车牌'}{FLOW_META[item.type].label}{item.eventTime || '—'}{item.submitTime || '—'}{dimensionLabel('department', dimensionValue(item, 'department'))}{item.manager || '—'}{item.customerName || '—'}{item.contractNo || '—'}{item.projectName || '—'}
} :
当前分组暂无业务明细,请返回统计调整条件。
}
+ {mobile &&
统计口径

按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。

}
; } diff --git a/src/modules/assets/components/analysis/AnalysisControls.tsx b/src/modules/assets/components/analysis/AnalysisControls.tsx index d383004..425051a 100644 --- a/src/modules/assets/components/analysis/AnalysisControls.tsx +++ b/src/modules/assets/components/analysis/AnalysisControls.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react'; import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics'; -import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model'; +import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model'; +import { useMobileLayout } from '../../useMobileLayout'; +import { MobileAnalysisControls } from './MobileAnalysisControls'; type Props = { view: AnalysisView; @@ -15,6 +17,7 @@ type Props = { }; export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) { + const mobile = useMobileLayout(); const root = useRef(null); const triggerRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); @@ -56,6 +59,14 @@ export function AnalysisControls({ view, cells, available, dimensions, setDimens if (draft.length) next[editor] = draft; else delete next[editor]; setFilters(next); closeEditor(); }; + const mobileOptions = useMemo(() => mobile ? available.map(key => { + const other = { ...filters }; delete other[key]; + const counts = new Map(); + filterCustomCells(cells, other, metric).forEach(cell => counts.set(cell[key], (counts.get(cell[key]) || 0) + cell.quantity)); + for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0); + return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(key, value) })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN')) }; + }) : [], [mobile, available, cells, filters, metric]); + if (mobile) return { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); }} />; return

分析维度

点名称组合统计 · 点箭头搜索筛选
diff --git a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx index 1e7be3b..4c33a12 100644 --- a/src/modules/assets/components/analysis/AssetAnalysisModal.tsx +++ b/src/modules/assets/components/analysis/AssetAnalysisModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react'; import { fetchJson } from '../../../../auth/api-client'; import Blur from '../../../../components/Blur'; @@ -15,8 +15,12 @@ import type { VehicleListItem } from '../../types'; import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails'; import { useDialogFocus } from '../useDialogFocus'; import { AnalysisExportButton } from './AnalysisExportButton'; +import { MobileAnalysisControls } from './MobileAnalysisControls'; +import { MobileGroupedTable } from './MobileGroupedTable'; import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export'; +import { useMobileLayout } from '../../useMobileLayout'; import './asset-analysis.css'; +import './mobile-analysis.css'; type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void }; const number = (value: number) => value.toLocaleString('zh-CN'); @@ -36,6 +40,7 @@ export function AssetAnalysisModal({ selection, subject, close }: Props) { } function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) { + const mobile = useMobileLayout(); const dialog = useRef(null); const scrollRoot = useRef(null); useDialogFocus(true, dialog, close); @@ -51,6 +56,9 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi const [mergeParents, setMergeParents] = useState(true); const [detailGroup, setDetailGroup] = useState(null); const showDetails = detailGroup !== null; + const overviewScroll = useRef(0); + const openDetails = (row: CustomAnalysisRow) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailGroup(row); }; + useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]); const [vehicles, setVehicles] = useState([]); const [detailBusy, setDetailBusy] = useState(false); const [detailError, setDetailError] = useState(''); @@ -107,7 +115,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi : analysis.rows, [analysis, merged, sort]); const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]); const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]); - const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40 }); + const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40, preserveOnDisable: true }); const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]); const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]); const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]); @@ -132,17 +140,21 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi }; return
-

{title}

{scopeLabel}

- {busy ?
正在汇总统计数据…
: error ?

统计数据加载失败:{error}

: request.kind === 'weekly' ? : <> - {showDetails &&
{detailGroup.label}
} + {(!mobile || request.kind !== 'weekly' || busy || error) &&
{mobile && }

{title}

{scopeLabel}

} + {busy ?
正在汇总统计数据…
: error ?

统计数据加载失败:{error}

: request.kind === 'weekly' ? : <> + {showDetails && !mobile &&
{detailGroup.label}
}
{!showDetails && <> - + {!mobile && }
当前结果 {number(analysis.total)} 台{analysis.rows.length} 个分组{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}
{view === 'inventory' &&
待交车 {number(analysis.summary.pending)} 台异动 {number(analysis.summary.abnormal)} 台已包含在当前库存数量中
} - {view === 'abnormal' &&

已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}

} -

{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}

{analysis.dimensions.length > 1 && }
- {analysis.total === 0 ?

没有符合条件的数据

移除部分筛选条件,或恢复当前入口的全部数据。

{Object.keys(filterValues).length > 0 && }
: <> + {mobile && (analysis.total > 0 || Object.keys(filterValues).length > 0) && } + {!mobile && view === 'abnormal' &&

已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}

} +

{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}

{analysis.dimensions.length > 1 && }
+ {analysis.total === 0 ?

{Object.keys(filterValues).length ? '没有符合条件的数据' : view === 'abnormal' ? '当前无异动车辆' : view === 'pending' ? '当前无待交车辆' : '当前范围暂无车辆'}

{Object.keys(filterValues).length ? '可移除筛选条件后重新查看。' : '当前可查看范围内没有相关记录。'}

{Object.keys(filterValues).length > 0 && }
: <> + {mobile ? DIMENSION_LABELS[d])} unit="台" merged={merged} setMerged={setMergeParents} spans={spans} parentTotals={parentTotals} + rows={shownGroups.map(row => ({ key: row.key, label: row.label, values: analysis.dimensions.map(d => {getDimensionValueLabel(d, row.values[d] || '')}), count: row.count, share: row.share, note: view === 'inventory' && (row.totals.pending || row.totals.abnormal) ? [row.totals.pending ? `待交 ${row.totals.pending}` : '', row.totals.abnormal ? `异动 ${row.totals.abnormal}` : ''].filter(Boolean).join(' · ') : undefined }))} + onDetails={index => openDetails(shownGroups[index])} onFilter={index => narrowToGroup(shownGroups[index])} /> : <>
{analysis.dimensions.length ? analysis.dimensions.map(dimension => ) : }{view === 'inventory' && <>}{shownGroups.map((row, rowIndex) => 0 ? 'analysis-group-start' : undefined}> @@ -153,14 +165,15 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi {view === 'inventory' && <>} - + )}
{DIMENSION_LABELS[dimension]}统计范围数量(台)待交车异动占比分析操作
{number(row.count)}{number(row.totals.pending)}{number(row.totals.abnormal)}{row.share.toFixed(1)}%{analysis.dimensions.length > 0 && }{analysis.dimensions.length > 0 && }
+ }
合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果100%
} } - {showDetails &&
+ {showDetails &&
{mobile &&

{detailGroup.label} · 当前分组

}

车辆明细 {number(visibleVehicles.length)} 台

{detailBusy ?
正在加载车辆明细…
: detailError ?

{detailError}

: <> {vehicles.length !== detailGroup.count &&

车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。

} @@ -169,19 +182,26 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi }
}
-
统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}
+
{mobile ?
统计口径与数据时间

统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围

同维度筛选为“或”,不同维度为“且”。仅展示当前入口与筛选条件内的车辆。

{view === 'abnormal' &&

已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}

}
: <>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}}
}
; } function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) { + const mobile = useMobileLayout(); if (!vehicles.length) return
暂无匹配车辆
; const stateLabel = (vehicle: VehicleListItem) => { const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record)[vehicle.operationStatus || ''] || vehicle.operationStatus; const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record)[vehicle.status] || vehicle.status; return [business, operation].filter(Boolean).join(' · '); }; + if (mobile) return
{vehicles.map(vehicle =>
+

{vehicle.plateNumber || vehicle.vin || '未提供'}

{stateLabel(vehicle)}
+

{vehicle.model || `${vehicle.brandLabel || '—'} · ${vehicle.type}`} · {vehicle.city || vehicle.location || '未标注城市'}

+ {!inventory && vehicle.customerName &&

{vehicle.customerName}

} +
车辆资料
{[['VIN', vehicle.vin], ['品牌 / 车型', `${vehicle.brandLabel || '—'} · ${vehicle.type}`], ['省份 / 城市', `${vehicle.province || '未标注'} / ${vehicle.city || vehicle.location || '未标注'}`], ...(inventory ? [['运维负责人', vehicle.opsManagers?.trim() || '-']] : [['签约公司', vehicle.orgName], ['部门', vehicle.departmentName], ['业务负责人', vehicle.customerManager]]), ['所属公司', vehicle.subjectOrg]].map(([label, value]) =>
{label}
{value || '未提供'}
)}
+
)}
; return
{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].map(label => )}{vehicles.map(vehicle => {inventory ? : <>})}
{label}
{vehicle.plateNumber || vehicle.vin || '未提供'}{vehicle.brandLabel || '—'} · {vehicle.type}{vehicle.model}{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}{stateLabel(vehicle)}{vehicle.opsManagers?.trim() || '-'}{vehicle.customerName || '—'}{vehicle.orgName || '—'}{vehicle.departmentName || '—'}{vehicle.customerManager || '—'}{vehicle.subjectOrg || '—'}
; } @@ -191,7 +211,8 @@ const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = []; const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date' ? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || ''; -function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) { +function WeeklyAnalysis({ rows, title, scopeLabel, close }: { rows: WeeklyDetailItem[]; title: string; scopeLabel: string; close: () => void }) { + const mobile = useMobileLayout(); type Dimension = WeeklyDimension; const dimensionsAvailable = WEEKLY_DIMENSIONS; const scrollRoot = useRef(null); @@ -222,12 +243,15 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) { const detailGroup = detailKey === null ? null : grouped.get(detailKey); const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS; const showDetails = detailKey !== null; + const overviewScroll = useRef(0); + const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); }; + useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]); const groupLabel = (group: { values: string[] }) => dimensions.length ? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ') : '当前筛选结果'; const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]); const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]); - const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails }); + const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true }); const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails }); const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]); const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]); @@ -256,6 +280,25 @@ function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) { const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name })); return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`); }; + if (mobile) return <> +

{title}

{scopeLabel}

+
+
当前结果 {number(showDetails ? detailRows.length : filtered.length)} 车次{!showDetails && {groups.length} 个分组}
+ {!showDetails ? <> + {(filtered.length > 0 || activeFilters.length > 0) && ({ key, label: labels[key], options: filterOptions[key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { updateDimensions(nextDimensions as Dimension[]); updateFilters(nextFilters); }} />} + {filtered.length > 0 &&
} + {groups.length ? <> labels[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals} + rows={shownGroups.map(group => ({ key: group.key, label: groupLabel(group), values: group.values.map((entry, i) => label(dimensions[i], entry)), count: group.rows.length, share: group.rows.length / filtered.length * 100 }))} + onDetails={index => openDetails(shownGroups[index].key)} onFilter={index => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, i) => [dimension, [shownGroups[index].values[i]]])) })} /> :

{activeFilters.length ? '没有符合条件的数据,可调整筛选。' : '当前范围暂无交还车记录。'}

{activeFilters.length > 0 && }
} + : <> +

{detailGroup ? groupLabel(detailGroup) : '当前分组'}

+

交还车业务明细

+
{shownDetails.map((row, index) =>

{row.plate_number || '未登记车牌'}

{row.contract_type || '未标注合同类型'}

{row.handover_date || '日期缺失'}

{row.customer_name || '未提供客户'}

)}
+ + } +
+
统计口径

按当前入口的周业务记录统计车次,同一车辆多笔记录分别计次。仅展示当前可查看范围和筛选条件内的数据。

+ ; return
{!showDetails ? <>
diff --git a/src/modules/assets/components/analysis/useProgressiveRows.ts b/src/modules/assets/components/analysis/useProgressiveRows.ts index 6d88533..589a7f3 100644 --- a/src/modules/assets/components/analysis/useProgressiveRows.ts +++ b/src/modules/assets/components/analysis/useProgressiveRows.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; interface ProgressiveRowsOptions { total: number; @@ -6,11 +6,14 @@ interface ProgressiveRowsOptions { resetKey: unknown; enabled?: boolean; batchSize?: number; + preserveOnDisable?: boolean; } /** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */ -export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40 }: ProgressiveRowsOptions) { - const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]); +export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40, preserveOnDisable = false }: ProgressiveRowsOptions) { + const resetEnabled = preserveOnDisable || enabled; + const context = useMemo(() => ({ resetKey, enabled: resetEnabled, batchSize }), [resetKey, resetEnabled, batchSize]); + const scrollContext = useRef(context); const [progress, setProgress] = useState(() => ({ context, limit: batchSize })); const [sentinel, setSentinel] = useState(null); const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize); @@ -21,8 +24,9 @@ export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, b }, [enabled, context, total, batchSize]); useEffect(() => { - if (enabled && rootRef.current) rootRef.current.scrollTop = 0; - }, [context, enabled, rootRef]); + if (enabled && rootRef.current && (!preserveOnDisable || scrollContext.current !== context)) rootRef.current.scrollTop = 0; + scrollContext.current = context; + }, [context, enabled, rootRef, preserveOnDisable]); useEffect(() => { const root = rootRef.current; diff --git a/src/server/auth/preview/routes.ts b/src/server/auth/preview/routes.ts index bd2024e..c82391f 100644 --- a/src/server/auth/preview/routes.ts +++ b/src/server/auth/preview/routes.ts @@ -43,7 +43,7 @@ export function createPreviewRouter(service: Service = previewService) { return router; } -// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default. +// These POSTs do not change business data. AI previews suppress gap feedback but retain attributed question audit records. const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']); export function createPreviewMiddleware(service: Service = previewService) { return async (c: Context, next: Next) => { diff --git a/src/server/routes/asset-assistant/analysis.test.ts b/src/server/routes/asset-assistant/analysis.test.ts index ba1d5d6..9aa9454 100644 --- a/src/server/routes/asset-assistant/analysis.test.ts +++ b/src/server/routes/asset-assistant/analysis.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/boss-experience.test.ts b/src/server/routes/asset-assistant/boss-experience.test.ts index 8016fa9..9bf1cca 100644 --- a/src/server/routes/asset-assistant/boss-experience.test.ts +++ b/src/server/routes/asset-assistant/boss-experience.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/composition.test.ts b/src/server/routes/asset-assistant/composition.test.ts index 0a10514..ac0921b 100644 --- a/src/server/routes/asset-assistant/composition.test.ts +++ b/src/server/routes/asset-assistant/composition.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/experience.test.ts b/src/server/routes/asset-assistant/experience.test.ts index e7eb5da..e2dbb6b 100644 --- a/src/server/routes/asset-assistant/experience.test.ts +++ b/src/server/routes/asset-assistant/experience.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/generative-route.test.ts b/src/server/routes/asset-assistant/generative-route.test.ts index c573802..6e78b43 100644 --- a/src/server/routes/asset-assistant/generative-route.test.ts +++ b/src/server/routes/asset-assistant/generative-route.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/index.ts b/src/server/routes/asset-assistant/index.ts index 601a13e..60ee9ce 100644 --- a/src/server/routes/asset-assistant/index.ts +++ b/src/server/routes/asset-assistant/index.ts @@ -21,6 +21,7 @@ import { } from "./session.js"; import { resultScope, followupSuggestions } from "./presentation.js"; import { gapStore } from "./gaps.js"; +import { questionLog, questionOutcome, finishQuestionLog } from "./question-log.js"; import { CATEGORY_LABELS, GAP_STATUSES, @@ -99,6 +100,10 @@ router.post("/feedback", async (c) => { const session = getSession(c.get("user"), body.subject, body.sessionId), node = getNode(session, body.resultId)!; if (!node.flagged) { + const logId = await questionLog.start(c.get("user"), { message: node.question || node.demand, + subject: body.subject, sessionId: session.id, baseResultId: node.id }, "feedback"); + const saved = await finishQuestionLog(logId, { outcome: "feedback", reason: "unsatisfied", resultId: node.id }); + if (!saved) return c.json({ error: "反馈已接收,处理状态暂未保存,请稍后重试。" }, 503); await gapStore.record(c.get("user").userId, node.demand, "unsatisfied"); node.flagged = true; } @@ -220,15 +225,24 @@ router.post("/ask", async (c) => { ) return c.json({ error: "请输入有效问题或选择分析操作。" }, 400); const input: AssistantRequest = { ...raw, subject: raw.subject || null }; + let logId: string; + try { + logId = await questionLog.start(user, input); + } catch { + return c.json({ error: "提问记录暂不可用,本次查询未执行,请稍后重试。" }, 503); + } const release = acquire(user.userId); - if (!release) + if (!release) { + await finishQuestionLog(logId, { outcome: "rejected", reason: "busy" }); return c.json({ error: "上一条查询仍在执行,请稍后重试。" }, 429); + } let session, base; try { session = getSession(user, input.subject, input.sessionId); base = getNode(session, input.baseResultId); } catch { release(); + await finishQuestionLog(logId, { outcome: "rejected", reason: "session_expired" }); return c.json({ error: "会话已过期或范围已变化,请新建对话。" }, 409); } const currentSession = session, @@ -238,6 +252,7 @@ router.post("/ask", async (c) => { stream.onAbort(() => controller.abort()); const emit = (event: object) => stream.writeSSE({ data: JSON.stringify(event) }); + let logFinished = false; let demand = "查询执行", reason: GapReason | undefined; try { @@ -516,8 +531,17 @@ router.post("/ask", async (c) => { type: "step", label: "分析完成", }); + logFinished = true; + const saved = await finishQuestionLog(logId, { outcome: questionOutcome(result, gapReason), + reason: gapReason, sessionId: currentSession.id, result }); + if (!saved) result.warning = [result.warning, "提问已记录,处理结果暂未保存。"].filter(Boolean).join(" "); await emit({ type: "result", result }); } catch { + if (!logFinished) { + logFinished = true; + await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed", + reason: controller.signal.aborted ? "client_aborted" : "generation", sessionId: currentSession.id }); + } if (!controller.signal.aborted) { let recorded = false; try { @@ -534,6 +558,8 @@ router.post("/ask", async (c) => { }); } } finally { + if (!logFinished) await finishQuestionLog(logId, { outcome: controller.signal.aborted ? "cancelled" : "failed", + reason: controller.signal.aborted ? "client_aborted" : "unfinished", sessionId: currentSession.id }); release(); } }); diff --git a/src/server/routes/asset-assistant/model.test.ts b/src/server/routes/asset-assistant/model.test.ts index 3aa0ffc..49fc447 100644 --- a/src/server/routes/asset-assistant/model.test.ts +++ b/src/server/routes/asset-assistant/model.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test from "node:test"; import { Hono } from "hono"; diff --git a/src/server/routes/asset-assistant/question-log.test.ts b/src/server/routes/asset-assistant/question-log.test.ts new file mode 100644 index 0000000..44a9eae --- /dev/null +++ b/src/server/routes/asset-assistant/question-log.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import pool from '../../db.js'; +import router from './index.js'; +import { questionLog, questionOutcome, businessTypes, finishQuestionLog, redactQuestion, type QuestionCompletion } from './question-log.js'; +import { vehicleRepository } from '../vehicles/repository.js'; +import { gapStore } from './gaps.js'; +import { acquire } from './session.js'; +import type { AuthUser } from '../../auth/types.js'; +import type { AssistantResult } from '../../../shared/assets/assistant.js'; + +const user: AuthUser = { userId: 'log-test', userName: '测试用户', loginName: 'test', roles: [], permissionLevel: 'personal', depCode: 'D', depName: '测试部门' }; +const result = (extra: Partial = {}): AssistantResult => ({ + plan: { kind: 'assets', category: 'inventory', groupBy: 'region', view: 'table', filters: {} }, + title: '库存', summary: '库存共 2 台', rows: [], groups: [], metrics: [], source: 'test', fetchedAt: '', ...extra, +}); +function appFor(u = user) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', u); await next(); }); + app.route('/ai', router); + return app; +} +function ask(app: ReturnType, body: object = { message: '库存有多少车?', subject: null }) { + return app.request('/ai/ask', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); +} + +test('each question has its own durable ID, full text and attributed preview identity; SQL is parameterized', async t => { + const calls: { sql: string; values: unknown[] }[] = []; + t.mock.method(pool, 'query', async (options: { sql: string }, values: unknown[]) => { calls.push({ sql: options.sql, values }); return [{ affectedRows: 1 }, []]; }); + const preview = { ...user, preview: { actorId: 'real-actor', sessionId: 'preview-session' } }; + const message = '按区域统计库存'.repeat(100) + " ' OR 1=1 --"; + const first = await questionLog.start(preview, { message, subject: '公司甲' }); + const second = await questionLog.start(preview, { message, subject: '公司甲' }); + assert.notEqual(first, second); + assert.equal(calls[0].values[1], 'real-actor'); + assert.equal(calls[0].values[2], user.userId); + assert.equal(calls[0].values[6], 1); + assert.equal(calls[0].values[12], message); + assert.ok(!calls[0].sql.includes(message)); + await questionLog.finish(first, { outcome: 'success', sessionId: 'session', result: result() }); + assert.match(calls[2].sql, /outcome='processing'/); + assert.equal(calls[2].values[4], '["inventory"]'); + assert.match(String(calls[2].values[6]), /库存共 2 台/); + assert.ok(!String(calls[2].values[6]).includes('rows')); +}); + +test('outcomes and business categories separate partial, help, empty, unsupported and failure', () => { + assert.equal(questionOutcome(result()), 'success'); + for (const value of ['partial', 'empty', 'unsupported', 'help', 'clarification'] as const) { + const r = result({ outcome: value }); + if (value === 'clarification') r.plan.kind = 'clarify'; + assert.equal(questionOutcome(r), value); + } + assert.equal(questionOutcome(result(), 'generation'), 'failed'); + assert.deepEqual(businessTypes(result({ sections: [ + { id: 'a', title: '库存', question: '库存', display: 'table', result: result() }, + { id: 'b', title: '交车', question: '交车', display: 'table', result: result({ plan: { ...result().plan, kind: 'flow' } }) }, + ] })), ['inventory', 'flow']); + assert.equal(redactQuestion('库存 sk-12345678 password=secret123 Bearer token123'), '库存 [凭据已隐藏] password=[凭据已隐藏] Bearer [凭据已隐藏]'); +}); + +test('storage outage rejects the request before querying data or invoking AI', async t => { + t.mock.method(questionLog, 'start', async () => { throw new Error('private DB error'); }); + const query = t.mock.method(vehicleRepository, 'getVehicles', async () => []); + const response = await ask(appFor()); + assert.equal(response.status, 503); + assert.equal(query.mock.callCount(), 0); + assert.ok(!(await response.text()).includes('private DB')); +}); + +test('concurrency rejection and expired session are still durable classified questions', async t => { + const start = t.mock.method(questionLog, 'start', async () => 'id'); + const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {}); + const release = acquire(user.userId)!; + try { assert.equal((await ask(appFor())).status, 429); } finally { release(); } + assert.equal((await ask(appFor(), { message: '追问', subject: null, sessionId: 'missing' })).status, 409); + assert.equal(start.mock.callCount(), 2); + assert.equal(finish.mock.calls[0].arguments[1].reason, 'busy'); + assert.equal(finish.mock.calls[1].arguments[1].reason, 'session_expired'); +}); + +test('failed queries are logged for preview users without creating gap feedback', async t => { + const start = t.mock.method(questionLog, 'start', async () => 'id'); + const finish = t.mock.method(questionLog, 'finish', async (_id: string, _completion: QuestionCompletion) => {}); + const gap = t.mock.method(gapStore, 'record', async () => {}); + t.mock.method(vehicleRepository, 'getVehicles', async () => { throw new Error('private failure'); }); + const response = await ask(appFor({ ...user, preview: { actorId: 'actor', sessionId: 'preview' } })); + const text = await response.text(); + assert.match(text, /查询未完成/); + assert.ok(!text.includes('private failure')); + assert.equal(start.mock.callCount(), 1); + assert.equal(finish.mock.calls[0].arguments[1].outcome, 'failed'); + assert.equal(gap.mock.callCount(), 0); +}); + +test('finalization retries transient failures and reports persistent failures', async t => { + let calls = 0, alwaysFail = false; + t.mock.method(questionLog, 'finish', async () => { if (++calls === 1 || alwaysFail) throw new Error('transient'); }); + assert.equal(await finishQuestionLog('id', { outcome: 'success' }), true); + assert.equal(calls, 2); + alwaysFail = true; + const warning = t.mock.method(console, 'error', () => {}); + assert.equal(await finishQuestionLog('id', { outcome: 'failed' }), false); + assert.deepEqual(warning.mock.calls[0].arguments, ['asset_ai_question_completion_failed', 'id']); +}); + +test('successful questions, follow-ups, actions and feedback keep distinct operation types', async t => { + const statements: unknown[][] = []; + t.mock.method(pool, 'query', async (options: { sql?: string } | string, values: unknown[]) => { + if (typeof options !== 'string' && options.sql?.includes('bi_asset_ai_questions')) statements.push(values); + return [[], []]; + }); + t.mock.method(vehicleRepository, 'getVehicles', async () => []); + t.mock.method(gapStore, 'record', async () => {}); + const old = process.env.ASSET_AI_EXTERNAL_ENABLED; + process.env.ASSET_AI_EXTERNAL_ENABLED = '0'; + t.after(() => { if (old === undefined) delete process.env.ASSET_AI_EXTERNAL_ENABLED; else process.env.ASSET_AI_EXTERNAL_ENABLED = old; }); + const app = appFor({ ...user, userId: 'success-test' }); + const response = await ask(app); + const events = (await response.text()).split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5))); + const r = events.find(event => event.type === 'result')?.result as AssistantResult; + assert.ok(r, JSON.stringify(events)); + assert.equal(statements[0][11], 'question'); + assert.ok(['success', 'empty'].includes(String(statements[1][0]))); + assert.equal(statements[1][2], r.sessionId); + assert.equal(statements[1][3], r.resultId); + assert.deepEqual(JSON.parse(String(statements[1][4])), ['inventory']); + await questionLog.start(user, { message: '按区域看', subject: null, sessionId: r.sessionId }); + assert.equal(statements.at(-1)![11], 'followup'); + await questionLog.start(user, { subject: null, sessionId: r.sessionId, baseResultId: r.resultId, action: { type: 'category', category: 'abnormal' } }); + assert.equal(statements.at(-1)![11], 'action'); + assert.deepEqual(JSON.parse(String(statements.at(-1)![13])), { type: 'category', category: 'abnormal' }); + const feedback = await app.request('/ai/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: r.sessionId, resultId: r.resultId, subject: null }) }); + assert.equal(feedback.status, 200); + assert.equal(statements.at(-2)![11], 'feedback'); + assert.equal(statements.at(-1)![0], 'feedback'); +}); + +test('client cancellation finalizes the durable question and releases the user query lock', async t => { + t.mock.method(questionLog, 'start', async () => 'cancel-id'); + let complete!: (value: QuestionCompletion) => void; + const completed = new Promise(resolve => { complete = resolve; }); + t.mock.method(questionLog, 'finish', async (_id: string, completion: QuestionCompletion) => { complete(completion); }); + let continueQuery!: () => void; + t.mock.method(vehicleRepository, 'getVehicles', async () => { await new Promise(resolve => { continueQuery = resolve; }); return []; }); + const cancelledUser = { ...user, userId: 'cancel-test' }; + const response = await ask(appFor(cancelledUser)); + const reader = response.body!.getReader(); + await reader.read(); + await reader.cancel(); + continueQuery(); + const completion = await completed; + assert.equal(completion.outcome, 'cancelled'); + // Finalizer releases the lock after the asynchronous database update. + await new Promise(resolve => setImmediate(resolve)); + const release = acquire(cancelledUser.userId); + assert.ok(release); + release(); +}); diff --git a/src/server/routes/asset-assistant/question-log.ts b/src/server/routes/asset-assistant/question-log.ts new file mode 100644 index 0000000..418471a --- /dev/null +++ b/src/server/routes/asset-assistant/question-log.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto'; +import pool from '../../db.js'; +import type { AuthUser } from '../../auth/types.js'; +import type { AssistantRequest, AssistantResult, GapReason } from '../../../shared/assets/assistant.js'; + +export type QuestionOutcome = 'processing' | 'success' | 'empty' | 'partial' | 'clarification' | 'help' | 'unsupported' | 'failed' | 'cancelled' | 'rejected' | 'feedback'; +export type QuestionCompletion = { + outcome: QuestionOutcome; + sessionId?: string; + resultId?: string; + result?: AssistantResult; + reason?: string; +}; +// Credentials are never useful for question analysis. Keep the rest of the accepted question intact. +export function redactQuestion(value: string): string { + return value.replace(/sk-[a-z0-9_-]{8,}/gi, '[凭据已隐藏]') + .replace(/(Bearer\s+)[\w.+/=-]+/gi, '$1[凭据已隐藏]') + .replace(/((?:password|passwd|api[_-]?key|access[_-]?token|secret|密码|口令)\s*[:=:]\s*)[^\s,,;;]+/gi, '$1[凭据已隐藏]'); +} +export function questionOutcome(result: AssistantResult, reason?: GapReason): QuestionOutcome { + if (result.outcome === 'partial' || result.unanswered?.length) return 'partial'; + if (reason === 'unsupported' || result.outcome === 'unsupported') return 'unsupported'; + if (reason === 'generation') return 'failed'; + if (result.outcome === 'help' || result.answerKind === 'help') return 'help'; + if (result.plan.kind === 'clarify') return 'clarification'; + if (result.outcome === 'empty' || reason === 'empty') return 'empty'; + return 'success'; +} +export function businessTypes(result?: AssistantResult): string[] { + if (!result) return []; + const items = result.sections?.flatMap(section => section.result ? [section.result] : []) || [result]; + return [...new Set(items.map(item => item.plan.kind === 'flow' ? 'flow' : item.plan.kind === 'clarify' ? 'general' : item.plan.category))]; +} +export const questionLog = { + async start(user: AuthUser, input: AssistantRequest, eventType?: 'feedback'): Promise { + const id = randomUUID(); + await pool.query({ sql: `INSERT INTO bi_asset_ai_questions + (id,actor_id,user_id,login_name,user_name,permission_level,is_preview,preview_session_id, + session_id,base_result_id,subject,event_type,question,action_json,outcome,started_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,'processing',UTC_TIMESTAMP(3))`, timeout: 5000 }, [ + id, user.preview?.actorId || user.userId, user.userId, user.loginName, user.userName, + user.permissionLevel, user.preview ? 1 : 0, user.preview?.sessionId || null, + input.sessionId || null, input.baseResultId || null, input.subject, + eventType || (input.action ? 'action' : input.sessionId ? 'followup' : 'question'), + input.message ? redactQuestion(input.message) : null, + input.action ? JSON.stringify(input.action, (_key, value) => typeof value === "string" ? redactQuestion(value) : value) : null, + ]); + return id; + }, + async finish(id: string, completion: QuestionCompletion): Promise { + const result = completion.result; + // Persist the textual answer and classification, not a duplicate of vehicle detail datasets. + await pool.query({ sql: `UPDATE bi_asset_ai_questions SET outcome=?,reason=?,session_id=COALESCE(?,session_id), + result_id=?,business_types=?,engine=?,answer=?,finished_at=UTC_TIMESTAMP(3), + duration_ms=TIMESTAMPDIFF(MICROSECOND,started_at,UTC_TIMESTAMP(3)) DIV 1000 + WHERE id=? AND outcome='processing'`, timeout: 5000 }, [ + completion.outcome, completion.reason || null, completion.sessionId || null, + completion.resultId || result?.resultId || null, JSON.stringify(businessTypes(result)), + result?.engine || null, result ? redactQuestion(JSON.stringify({ summary: result.summary, warning: result.warning, + unanswered: result.unanswered, sections: result.sections?.map(section => ({ title: section.title, summary: section.result?.summary })) })) : null, id, + ]); + }, +}; + +/** A failed update never erases the durable original question. Retry once and expose failure to the caller. */ +export async function finishQuestionLog(id: string, completion: QuestionCompletion): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + try { await questionLog.finish(id, completion); return true; } catch { /* retry transient failures */ } + } + console.error('asset_ai_question_completion_failed', id); + return false; +} diff --git a/src/server/routes/asset-assistant/vehicle-answer.acceptance.test.ts b/src/server/routes/asset-assistant/vehicle-answer.acceptance.test.ts index 452a015..2c780cb 100644 --- a/src/server/routes/asset-assistant/vehicle-answer.acceptance.test.ts +++ b/src/server/routes/asset-assistant/vehicle-answer.acceptance.test.ts @@ -1,3 +1,10 @@ +import { beforeEach } from 'node:test'; +import { questionLog } from './question-log.js'; +beforeEach(t => { + if (!('mock' in t)) throw new Error('Expected test context'); + t.mock.method(questionLog, 'start', async () => 'synthetic-question'); + t.mock.method(questionLog, 'finish', async () => {}); +}); import assert from "node:assert/strict"; import test, { type TestContext } from "node:test"; import { Hono } from "hono"; From f107e96d3cbfee287a27ae55ab57732e0810f8ad Mon Sep 17 00:00:00 2001 From: shishengliang Date: Fri, 16 Oct 2026 12:38:52 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(assets):=20=E5=AE=8C=E5=96=84=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E9=A3=8E=E9=99=A9=E5=A4=9A=E7=BB=B4=E5=88=86=E6=9E=90?= =?UTF-8?q?=E4=B8=8E=E7=AE=A1=E7=90=86=E6=80=BB=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-qa.md | 79 +++++++++++ docs/asset-fault-management.md | 20 +++ src/modules/assets/faults/DashboardPanels.tsx | 42 ++++++ .../assets/faults/FaultAnalysisModal.tsx | 4 +- src/modules/assets/faults/FaultManagement.tsx | 127 +++++++++--------- src/modules/assets/faults/FaultOverview.tsx | 69 ++++++++++ src/modules/assets/faults/FaultTrendCard.tsx | 19 +-- .../assets/faults/FaultWeeklyReport.tsx | 17 ++- src/modules/assets/faults/dashboard-export.ts | 17 +++ src/modules/assets/faults/dashboard.css | 125 +++++++++++++++++ src/modules/assets/faults/report-export.ts | 3 +- .../routes/asset-faults/dashboard.test.ts | 42 ++++++ src/server/routes/asset-faults/dashboard.ts | 67 +++++++++ src/server/routes/asset-faults/index.ts | 17 ++- src/server/routes/asset-faults/model.ts | 9 +- src/server/routes/asset-faults/repository.ts | 1 + src/shared/assets/faults.ts | 16 +++ 17 files changed, 592 insertions(+), 82 deletions(-) create mode 100644 design-qa.md create mode 100644 src/modules/assets/faults/DashboardPanels.tsx create mode 100644 src/modules/assets/faults/FaultOverview.tsx create mode 100644 src/modules/assets/faults/dashboard-export.ts create mode 100644 src/modules/assets/faults/dashboard.css create mode 100644 src/server/routes/asset-faults/dashboard.test.ts create mode 100644 src/server/routes/asset-faults/dashboard.ts diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..4cdec91 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,79 @@ +# 故障管理 BI 设计验收 + +final result: passed + +## 最新调整:总览视觉与多维分析对齐 + +管理总览复用多维分析的页面色板与标题布局,统一浅蓝背景、白色7px圆角面板、蓝色标题标记、细边框按钮、日期控件和表格。趋势图已闭环/未闭环使用与分布分析一致的蓝/橙色;周报新增/解决仍用蓝/绿语义色。统计和排列顺序不变。 + +- 桌面:`../outputs/fault-overview-theme-20261013/desktop.jpg`(1448×1086);手机:`../outputs/fault-overview-theme-20261013/mobile.jpg`(390×844),均已目视检查。 +- 两页背景均为 rgb(248,251,255),共用主蓝、文字及边框变量;手机四卡132px等高,scrollWidth=390,无横向溢出。 +- 页签往返、手机日期选择打开/关闭正常,趋势和周报导出入口保留。类型检查、生产构建和diff检查通过;本次验证控制台无新增error/warn。 +- 未改动数据接口或统计口径;未重复执行数据库测试与下载导出文件。 + +## 最新调整:管理总览与处理效率合并 + +按用户最新要求,管理总览恢复原来的页面及 `/overview` 统计,依次展示实时汇总、故障处理趋势、故障运维周报;移除独立处理效率页签,保留多维分析、故障明细。以下原参考设计验收为历史记录,管理总览以本段及最新截图为准。 + +- 桌面证据:`../outputs/fault-overview-merged-20261013/desktop.jpg`,1448×1086。 +- 手机证据:`../outputs/fault-overview-merged-20261013/mobile.jpg`,390×844;四卡均为132px,无页面横向溢出。 +- 验证原今日新增、本日解决、当前在办及累计台账,当前在办下钻27单;页签往返正常,三个统计区同页出现,周报恢复默认业务周。 +- TypeScript、生产构建、diff检查通过;本次未修改后端统计口径。页面非空、无框架覆盖层,最终验证期间控制台无新增error/warn。 +- 保留趋势与周报的独立日期、下钻和导出按钮;本次未重复下载导出文件。已清除临时viewport覆盖。 + +## 对照依据 + +- 来源:`/var/folders/3b/z3fk0dm9419dk7qffdvrwmfh0000gn/T/codex-clipboard-1efea2c0-2930-4600-b725-874c3c2a1f4f.png` +- 实现:`http://127.0.0.1:4310/asset#faults` +- 桌面截图:`../outputs/fault-dashboard-20261013/desktop.jpg` +- 同图左右对照:`../outputs/fault-dashboard-20261013/comparison.jpg`(左参考,右实现) +- 局部对照:`../outputs/fault-dashboard-20261013/comparison-filters-kpis.jpg`(筛选、指标、提示条) +- 参考和实现各 1448×1086 像素;CSS viewport 1448×1086,DPR 1,无密度缩放。比较图片由两份截图并排组成,展示工具可能缩小,局部图另行检查。 +- 状态:浅色、管理总览、2026-09-14 至 2026-10-13、全部授权数据。参考使用示例数据,实现使用实际数据库;数值、分类数量和客户名称不要求与示例相同。 +- 手机:390×844 `mobile.jpg`;320×844 `mobile-320.jpg`。另有 `efficiency.jpg`、`drilldown.jpg`。 + +## Findings / 比较与修复历史 + +1. [P2,已修复] 现有应用顶部浮动面包屑与新标题重叠。通过仅作用于故障页面的 shell 选择器隐藏重复面包屑;最终桌面、手机截图无重叠。 +2. [P2,已修复] 第一版分析面板和列表过长,重点跟进过度后移。车型默认前三、客户默认前四、跟进默认前三,提供全部入口;减小图表行距、等级间距和桌面表格单元格内边距。最终 `comparison.jpg` 显示完整指标、分析面板及跟进入口。 +3. [P2,已修复] 分布图自动跳过部分区域标签。YAxis interval=0;补充柱尾数量,包括未闭环为零的组,保留精确数量表与下钻。 +4. [P2,已修复] 320px 下标题和导出挤占,原生筛选框太窄。标题操作区换行,筛选单列,指标标题字号适配。`mobile-320.jpg` 显示全部入口及所选值,没有横向页面溢出。 +5. 最终对照无未解决的 P0/P1/P2。以下为有意保留的业务差异,而非像素复刻:现有导航品牌与功能、页面水印、今日新增/本日解决/累计台账、未标注等级及部位、真实长车型名称和运维处置人第二列。实际内容略长,重点跟进明细需继续向下滚动;避免通过隐藏未知数据或缩小明细字体模拟示例高度。 + +## 必查表面 + +- 字体:沿用应用中文无衬线回退,桌面标题 24px、面板标题 14px、指标 29px,层级与参考一致;重点检查筛选、数字与窄屏换行。没有以图片模拟文字。 +- 间距:四指标等高、两行双列分析面板、统一边框与圆角;手机两列指标、分析面板单列,宽表内部横滚。 +- 颜色:浅蓝页面、白底面板、蓝色主操作;橙色未闭环、红色逾期,未知等级灰色。等级与实际停运含义分开说明。 +- 图像与图标:参考主要为数据图表。使用现有 Lucide 图标及真实数据图表;沿用产品已有品牌导航,未自行绘制替代参考 ONE OS 标识。无新增位图资产或虚构装饰图。 +- 文案与数据:使用真实口径和实际分类;“当前在办=待处理+处理中”,逾期为在办子集;“处置记录”避免把 solve_process 当作当前工作流阶段。发生频率缺分母,停运与修复时长缺数据,明确显示待补。 + +## 功能与数据验收 + +- 新增 3 项后端测试;故障模块与报表导出测试合计 32 项全部通过。 +- TypeScript 检查、生产构建、git diff --check 通过。 +- 实库只读核对 63 个分组、48 个车型×部位单元格,汇总与下钻一致;在办 26=17+9,逾期5,本期106,车辆去重89。 +- 浏览器验证:华东筛选后总览39单,趋势39单,周报新增39/解决125;按月切换及报表下载;等级20单下钻、交叉单元格3单下钻、单条故障明细、十二维分析默认运维处置人/故障工单状态。 +- 手机更多筛选 L1-特急得到本期1单;其他区域空数据正确显示0和空态。320/390px 页面 scrollWidth 等于 viewport width。 +- 总览 Excel 下载成功,重新读取工作簿确认6张表、日期/筛选说明、冻结表头和数字106/26与页面一致。 +- 处理效率保留既有柱图、周报折线图、今日行提示、报表导出与下钻。 +- 最终验证时间段(08:40 UTC 后)无浏览器 error/warn;开发编辑过程曾有一次已修复的 HMR 导入错误,未计为运行期问题。 +- 浏览器测试后恢复管理总览、全部筛选,并清除临时 viewport override。 + +## Open Questions / 残余限制 + +- 故障发生频率需要同期服务车辆规模;实际停运、修复时长需要业务定义和数据来源,未捏造这些指标。 +- 未进行不同浏览器引擎实机测试;已验证当前 in-app 浏览器桌面和移动断点。 +- 只读接入,未修改业务数据库;本次改动未提交或推送。 + +## Implementation Checklist + +- [x] 参考布局落地并绑定真实数据。 +- [x] 汇总、业务筛选、分布、矩阵、客户和重点跟进下钻。 +- [x] 趋势/周报继承筛选并保留导出。 +- [x] 数据权限、空数据、移动端与导出验证。 +- [x] 同图对照、局部检查、问题修复和最终证据保存。 + +## Follow-up Polish + +- P3:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。 diff --git a/docs/asset-fault-management.md b/docs/asset-fault-management.md index ff921ad..3c075b9 100644 --- a/docs/asset-fault-management.md +++ b/docs/asset-fault-management.md @@ -79,3 +79,23 @@ - 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。 - XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。 - 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。 + +## 风险与服务分析总览(2026-10-13) + +故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。 + +- 期间新增故障:所选提报日期内的有效故障。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 +- 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。 +- 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。 +- 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。 +- 客户影响表:本期影响车辆去重;同一客户的当前在办不限提报日期。库存空客户单列库存车。仅展示本期存在故障的客户组。 +- 重点跟进:当前在办中优先展示逾期,同优先级按提报时间倒序。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。 + +新增只读字段:`fault_level`、`fault_categories`、`solve_process`。等级映射经 OneOS 字典核对:1=L1-特急、2=L2-紧急、3=L3-一般、4=L4-提示;缺失等级/部位独立显示未标注。处置记录使用 `solve_process`,不将 AI 流转字段 `process_status` 当作业务处理进度。没有同期服务车辆规模、实际停运和修复时长依据,发生频率禁用并提示缺少分母,停运和修复时长提示待补数据。 + +新增接口: +- `GET /api/asset-faults/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD`:授权范围内汇总、筛选项、分布、交叉矩阵、重点跟进和完整性信息。 +- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=cohort|open|waiting|processing|overdue|ledger|reported|closed`。cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。 +- 趋势、周报及其下钻接口同时支持上述业务筛选。所有接口先执行权限筛选,筛选项也仅来自授权记录,保持 no-store,不写业务数据库。 + +总览导出新增 6 张 Excel 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。 diff --git a/src/modules/assets/faults/DashboardPanels.tsx b/src/modules/assets/faults/DashboardPanels.tsx new file mode 100644 index 0000000..91f826b --- /dev/null +++ b/src/modules/assets/faults/DashboardPanels.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { Bar, BarChart, CartesianGrid, LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { ArrowUpRight, Info } from 'lucide-react'; +import type { FaultAnalysisRecord, FaultDashboardDimension, FaultDashboardResponse, FaultDashboardScope, FaultDistribution } from '../../../shared/assets/faults'; +export type DashboardDrill = (title: string, scope?: FaultDashboardScope, filters?: Record) => void; +const colors = ['#ef4444','#f59e0b','#2563eb','#93c5fd','#94a3b8']; +export function DashboardPanels({data, drill}:{data:FaultDashboardResponse;drill:DashboardDrill}) { + const [dimension,setDimension]=useState<'region'|'level'|'customer'|'model'>('region'); + const [metric,setMetric]=useState<'reported'|'vehicles'>('reported'); + const [expanded,setExpanded]=useState(false),[matrixExpanded,setMatrixExpanded]=useState(false),[customersExpanded,setCustomersExpanded]=useState(false); + const groups=data.distributions[dimension],shown=expanded?groups:groups.slice(0,8); + const levels=[...data.distributions.level].sort((a,b)=>(a.value==='未标注'?1:0)-(b.value==='未标注'?1:0)||a.value.localeCompare(b.value,'zh-CN')); + const models=matrixExpanded?data.matrix.models:data.matrix.models.slice(0,3); + const max=Math.max(1,...data.matrix.cells.map(c=>c.count)); + const customers=[...data.distributions.customer].sort((a,b)=>b.vehicles-a.vehicles||b.reported-a.reported); + const chartDrill=(datum:unknown,key:FaultDashboardDimension,title:string)=>{const row=(datum as {payload?:FaultDistribution})?.payload;if(row)drill(`${title} · ${row.label}`,'cohort',{[key]:row.value});}; + return <> +
+

故障分布与对比

按本期提报队列统计,截至当前的闭环状态。
+
{([['region','区域'],['level','程度'],['customer','客户'],['model','车型']] as const).map(([key,label])=>)}
+
{metric==='reported'?<>已闭环 {groups.reduce((n,r)=>n+r.closed,0)}未闭环 {groups.reduce((n,r)=>n+r.unclosed,0)}:按车辆去重 · 辆}
+ {shown.length?
+ v.length>12?`${v.slice(0,11)}…`:v}/> + {metric==='reported'?<>chartDrill(d,dimension,'故障分布')}>row.unclosed===0?row.reported:''} position="right" fontSize={10} fill="#334155"/>chartDrill(d,dimension,'故障分布')}>:chartDrill(d,dimension,'受影响车辆对应故障')}>}
:

所选条件下暂无故障。

} +
点击图表查看对应故障;车辆跨组可能重复,不直接累加。{groups.length>8&&}
+
查看精确数量与下钻{groups.map(r=>)}
分组故障量已闭环未闭环车辆
{r.reported}{r.closed}{r.unclosed}{r.vehicles}
+
+

故障程度

现有等级口径

按本期故障数量统计,点击等级查看明细。

+
{levels.map(row=>)}
+ {!levels.length&&

暂无等级统计。

} +

等级不等于实际停运影响,请结合车辆状态与服务记录判断。

未标注等级 {data.quality.missingLevel} 单;停运车辆与修复时长待补数据。

+
+
+
+

品牌车型 × 故障部位

点击交叉单元格查看对应故障。
{data.matrix.categories.map(c=>)}{models.map(m=>{data.matrix.categories.map(c=>{const count=data.matrix.cells.find(cell=>cell.model===m.value&&cell.category===c.value)?.count||0;return })})}
品牌车型{c.label}合计
{m.label}
{!models.length&&

暂无车型与部位数据。

}
一单多部位分别计入,合计按故障单去重。{data.matrix.models.length>3&&}
+

客户受影响情况

同一车辆在同一客户下去重。
{(customersExpanded?customers:customers.slice(0,4)).map((r,i)=>)}
#客户名称本期影响车辆当前在办
{i+1}
{!customers.length&&

暂无客户影响数据。

}
当前在办不限制提报日期;库存车单独展示。{customers.length>4&&}
+
+ ; +} +export function DashboardTicketTable({rows,updatedAt,drill}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill}) { + return
{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)})}
车辆 / 故障单号运维处置人区域 / 客户等级当前状态处置记录 / 故障描述提报时间操作
{row.plate}{row.code}{row.handlers.map(h=>h.label).join('、')}{row.cell.region}{row.customerLabel}{row.level||'未标注'}{row.status==='已归档'?'已闭环':row.status}{overdue&&已逾期}{row.progress||row.description||'暂无处置记录'}{row.reportedAt||'未记录'}
{!rows.length&&

当前条件下暂无故障。

}
; +} diff --git a/src/modules/assets/faults/FaultAnalysisModal.tsx b/src/modules/assets/faults/FaultAnalysisModal.tsx index 83db596..2f3accc 100644 --- a/src/modules/assets/faults/FaultAnalysisModal.tsx +++ b/src/modules/assets/faults/FaultAnalysisModal.tsx @@ -17,7 +17,7 @@ import '../components/analysis/mobile-analysis.css'; const available = FAULT_ANALYSIS_DIMENSIONS; // Page-lifetime memory only: closing a modal retains settings; a full reload clears them. const analysisSettings = new Map(); -export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[] } +export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[]; initialDetails?: boolean } export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) { const ref = useRef(null); useDialogFocus(true, ref, onClose); @@ -38,7 +38,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO const controller = new AbortController(); setError(''); setData(null); fetchJson(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal }) - .then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }); + .then(result => { if (!controller.signal.aborted) { setData(result); if (source?.initialDetails) setGroup({key:"all", values:{}, count:result.rows.length, share:100, recordIds:result.rows.map(r=>r.id)}); } }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }); return () => controller.abort(); }, [metric, revision, source?.url]); const records = useMemo(() => data?.rows || [], [data]); diff --git a/src/modules/assets/faults/FaultManagement.tsx b/src/modules/assets/faults/FaultManagement.tsx index ddb472a..4f0427d 100644 --- a/src/modules/assets/faults/FaultManagement.tsx +++ b/src/modules/assets/faults/FaultManagement.tsx @@ -1,64 +1,71 @@ -import { Fragment, useCallback, useEffect, useState } from 'react'; -import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react'; import { fetchJson } from '../../../auth/api-client'; -import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults'; -import { FaultAnalysisModal } from './FaultAnalysisModal'; -import { FaultWeeklyReport } from './FaultWeeklyReport'; -import { FaultTrendCard } from './FaultTrendCard'; +import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults'; +import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker'; +import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton'; +import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal'; +import { FaultOverview } from './FaultOverview'; +import { DashboardPanels, DashboardTicketTable, type DashboardDrill } from './DashboardPanels'; +import { downloadDashboard } from './dashboard-export'; import './fault-management.css'; - -export const FAULT_DEFINITIONS = { - reported: '按今日提报时间统计', closed: '按今日解决时间统计', - open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账', -}; -const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList }; -const metrics = ['reported', 'closed', 'open', 'ledger'] as const; -export default function FaultManagement() { - const [data, setData] = useState(null); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(true); - const [revision, setRevision] = useState(0); - const [metric, setMetric] = useState(null); - const close = useCallback(() => setMetric(null), []); - useEffect(() => { - const controller = new AbortController(); - setLoading(true); setError(''); - fetchJson('/api/asset-faults/overview', { signal: controller.signal }) - .then(setData).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) - .finally(() => { if (!controller.signal.aborted) setLoading(false); }); - return () => controller.abort(); - }, [revision]); - return
-

羚牛氢能-故障管理

{data ? `数据更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}
- {error &&
{error}
} -
-

故障数据实时汇总

{data?.date || '—'} · 北京时间
-
{metrics.map(key => { - const Icon = icons[key]; - return
- {key === 'open' && <> - -
- {(['waiting', 'processing'] as const).map((part, index) => - {index > 0 && } - - )} -
- } -
; - })}
-

当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

+import './dashboard.css'; +const tabs = ['管理总览','多维分析','故障明细'] as const; +function initialRange(){const end=new Date(Date.now()+8*3600000).toISOString().slice(0,10);return {start:new Date(Date.parse(end)-29*86400000).toISOString().slice(0,10),end};} +export default function FaultManagement(){ + const [range,setRange]=useState(initialRange),[filters,setFilters]=useState({}); + const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters})); + const [tab,setTab]=useState('管理总览'),[more,setMore]=useState(false); + const [data,setData]=useState(null),[loading,setLoading]=useState(true),[error,setError]=useState(''); + const [revision,setRevision]=useState(0),[source,setSource]=useState(null); + const [detailScope,setDetailScope]=useState('cohort'),[detailData,setDetailData]=useState(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false); + const [search,setSearch]=useState(''),[limit,setLimit]=useState(50); + const close=useCallback(()=>setSource(null),[]); + const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString(); + useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson(`/api/asset-faults/dashboard?${query}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e instanceof Error?e.message:'加载失败');}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,revision,tab]); + useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson(`/api/asset-faults/dashboard-analysis?${query}&scope=${detailScope}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setDetailData(d);}).catch(()=>{if(!c.signal.aborted)setDetailError('故障明细加载失败,请重试。');}).finally(()=>{if(!c.signal.aborted)setDetailLoading(false);});return()=>c.abort();},[tab,query,detailScope,revision]); + const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});}; + const t=data?.totals; + const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true})); + const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters); + const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—'; + const select=(key:FaultDashboardDimension)=>; + const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');}; + const navigation=; + if(tab==='管理总览')return ; + return
+

羚牛氢能 BI / 故障分析

故障风险与服务分析

从问题分布,追溯到车辆与处理进展。

更新时间:{data?new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'读取中'}data?downloadDashboard(data,Object.entries(applied.filters).filter(([,v])=>v).map(([k,v])=>`${FAULT_DASHBOARD_FILTERS[k as FaultDashboardDimension]}:${data.options[k as FaultDashboardDimension].find(o=>o.value===v)?.label||v}`).join(' · ')):undefined}/>
+ {navigation} +
{e.preventDefault();setApplied({...range,filters:{...filters}});}}>
{(['region','customer','model'] as const).map(select)}{more&&
{(['city','department','handler','level','category','inventory'] as const).map(select)}
}
+

期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&条件已修改,点击查询后生效。}

+ {error&&
{error}
} + {!data&&loading?
正在汇总故障分析…
:data&&
+ {tab==='多维分析'&&<> +
+

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+

涉及 {t?.customers} 家已关联客户按车辆去重

+
+
+

最长逾期 {t?.longestOverdueDays} 天在办子集

+

全部有效故障台账不限提报日期

- - - {metric && } -
; +
实时统计
+
{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}按数量描述,不代表故障发生率。
+ {tab==='多维分析'&&

组合维度,追溯故障

支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。

} + +
+
+

重点故障跟进

+ 当前在办 {t?.open} + 其中逾期 {t?.overdue} + +
+ +

沿用当前业务筛选,不限制提报日期;优先展示逾期故障,同优先级按提报时间倒序,最多展示 5 单。完整明细见“查看全部”。

+
+ } + {tab==='故障明细'&&

故障明细

按提报时间倒序 · {detailRows.length} 单
{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=>)}
{detailLoading?

正在加载故障明细…

:detailError?

{detailError}

:<>{detailRows.length>limit&&}}
} +
数据完整性:本期未标注等级 {data.quality.missingLevel} 单,未标注部位 {data.quality.missingCategory} 单,缺少车辆标识 {data.quality.missingVehicle} 单;在办中 {data.quality.missingDeadline} 单未设置截止时间,不计入逾期。
+
} + {source&&} +
; } diff --git a/src/modules/assets/faults/FaultOverview.tsx b/src/modules/assets/faults/FaultOverview.tsx new file mode 100644 index 0000000..617a435 --- /dev/null +++ b/src/modules/assets/faults/FaultOverview.tsx @@ -0,0 +1,69 @@ +import type { ReactNode } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; +import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react'; +import { fetchJson } from '../../../auth/api-client'; +import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults'; +import { FaultAnalysisModal } from './FaultAnalysisModal'; +import { FaultWeeklyReport } from './FaultWeeklyReport'; +import { FaultTrendCard } from './FaultTrendCard'; +import './fault-management.css'; + +export const FAULT_DEFINITIONS = { + reported: '按今日提报时间统计', closed: '按今日解决时间统计', + open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账', +}; +const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList }; +const metrics = ['reported', 'closed', 'open', 'ledger'] as const; +export function FaultOverview({ navigation }: { navigation: ReactNode }) { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + const [revision, setRevision] = useState(0); + const [metric, setMetric] = useState(null); + const close = useCallback(() => setMetric(null), []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); setError(''); + fetchJson('/api/asset-faults/overview', { signal: controller.signal }) + .then(result => { if (!controller.signal.aborted) setData(result); }).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [revision]); + return
+
+

羚牛氢能 BI / 故障分析

羚牛氢能-故障管理

实时掌握故障数量、闭环趋势与运维进展。

+
{data ? `更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}
+
+ {navigation} + {error &&
{error}
} +
+

故障数据实时汇总

{data?.date || '—'} · 北京时间
+
{metrics.map(key => { + const Icon = icons[key]; + return
+ {key === 'open' && <> + +
+ {(['waiting', 'processing'] as const).map((part, index) => + {index > 0 && } + + )} +
+ } +
; + })}
+

当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

+
+ + + {metric && } +
; +} diff --git a/src/modules/assets/faults/FaultTrendCard.tsx b/src/modules/assets/faults/FaultTrendCard.tsx index 4160129..2c7dce4 100644 --- a/src/modules/assets/faults/FaultTrendCard.tsx +++ b/src/modules/assets/faults/FaultTrendCard.tsx @@ -1,3 +1,4 @@ +import type { FaultDashboardFilters } from '../../../shared/assets/faults'; import { useCallback, useEffect, useRef, useState } from 'react'; import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react'; import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; @@ -14,9 +15,11 @@ function initialRange() { } const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' }; const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const; -export function FaultTrendCard({ refresh }: { refresh: number }) { - const [range, setRange] = useState(initialRange); +export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) { + const [range, setRange] = useState(() => period || initialRange()); const [granularity, setGranularity] = useState<'day' | 'month'>('day'); + const scopeKey = new URLSearchParams(scope).toString(); + useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -28,22 +31,22 @@ export function FaultTrendCard({ refresh }: { refresh: number }) { useEffect(() => { const controller = new AbortController(); setLoading(true); setError(''); setData(null); - const params = new URLSearchParams({ ...range, granularity }); + const params = new URLSearchParams({ ...range, granularity, ...scope }); fetchJson(`/api/asset-faults/trend?${params}`, { signal: controller.signal }) .then(result => { if (!controller.signal.aborted) setData(result); }) .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [range.start, range.end, granularity, revision, refresh]); + }, [range.start, range.end, granularity, revision, refresh, scopeKey]); const drill = (status: FaultTrendStatus, period?: string) => { if (!data || loading) return; - const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status }); + const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope }); if (period) params.set('period', period); setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'], periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` }); }; return
-

故障处理趋势

data ? downloadFaultReport({ kind: 'trend', data }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
{(['day', 'month'] as const).map(value => )}
+

故障处理趋势

data ? downloadFaultReport({ kind: 'trend', data, filterLabel: scopeLabel }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
{(['day', 'month'] as const).map(value => )}

按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。

{error ?
故障趋势加载失败,请稍后重试。
: loading || !data ?
正在加载故障趋势…
: <> @@ -56,8 +59,8 @@ export function FaultTrendCard({ refresh }: { refresh: number }) { { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} /> - { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#10b981" stackId="faults" maxBarSize={34} isAnimationActive={false} /> - { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#f59e0b" stackId="faults" maxBarSize={34} isAnimationActive={false} /> + { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.closed) drill('closed', period.period); }} fill="#2475ff" stackId="faults" maxBarSize={34} isAnimationActive={false} /> + { const period = data.periods.find(row => row.period === bar.payload?.period); if (period?.unclosed) drill('unclosed', period.period); }} fill="#ff981a" stackId="faults" maxBarSize={34} isAnimationActive={false} />
:

所选时间范围暂无新增故障

} diff --git a/src/modules/assets/faults/FaultWeeklyReport.tsx b/src/modules/assets/faults/FaultWeeklyReport.tsx index b9d1cb9..bb70a73 100644 --- a/src/modules/assets/faults/FaultWeeklyReport.tsx +++ b/src/modules/assets/faults/FaultWeeklyReport.tsx @@ -1,3 +1,4 @@ +import type { FaultDashboardFilters } from '../../../shared/assets/faults'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ClipboardList, RefreshCw } from 'lucide-react'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; @@ -11,8 +12,10 @@ import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisMod const metrics: FaultWeeklyMetric[] = ['reported', 'closed']; const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。'; -export function FaultWeeklyReport({ refresh }: { refresh: number }) { - const [range, setRange] = useState(() => getWeeklyFlowRange()); +export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) { + const [range, setRange] = useState(() => period || getWeeklyFlowRange()); + const scopeKey = new URLSearchParams(scope).toString(); + useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]); const [data, setData] = useState(null); const [error, setError] = useState(''); const [loading, setLoading] = useState(true); @@ -24,22 +27,22 @@ export function FaultWeeklyReport({ refresh }: { refresh: number }) { useEffect(() => { const controller = new AbortController(); setLoading(true); setData(null); setError(''); - fetchJson(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end })}`, { signal: controller.signal }) + fetchJson(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end, ...scope })}`, { signal: controller.signal }) .then(result => { if (!controller.signal.aborted) setData(result); }) .catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => controller.abort(); - }, [range.start, range.end, revision, refresh]); + }, [range.start, range.end, revision, refresh, scopeKey]); const drill = (metric: FaultWeeklyMetric, day?: string) => { if (!data || loading) return; - const params = new URLSearchParams({ start: data.start, end: data.end, metric }); + const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope }); if (day) params.set('day', day); setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`, periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition }); }; return
-

故障运维周报

单位:单 data ? downloadFaultReport({ kind: 'weekly', data }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
-

按日展示新增故障和解决故障。默认业务周为周六至周五,可自选日期。

+

故障运维周报

单位:单 data ? downloadFaultReport({ kind: 'weekly', data, filterLabel: scopeLabel }, chartRef.current?.querySelector('svg.recharts-surface[role=application]') || undefined) : undefined} />
+

按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}

{error ?
故障运维周报加载失败,请稍后重试。
: loading || !data ?
正在加载故障运维周报…
: <>
{metrics.map(metric => )}
diff --git a/src/modules/assets/faults/dashboard-export.ts b/src/modules/assets/faults/dashboard-export.ts new file mode 100644 index 0000000..82291b0 --- /dev/null +++ b/src/modules/assets/faults/dashboard-export.ts @@ -0,0 +1,17 @@ +import type { FaultDashboardResponse } from '../../../shared/assets/faults'; +export async function downloadDashboard(data:FaultDashboardResponse,filters:string) { + const {default:ExcelJS}=await import('exceljs');const book=new ExcelJS.Workbook();book.creator='羚牛氢能 BI'; + const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[ + {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['其中逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, + ...Object.entries(data.distributions).map(([key,rows])=>({name:({region:'区域分布',level:'故障程度',customer:'客户影响',model:'车型分布'} as Record)[key],headers:['分组','本期故障','已闭环','未闭环','影响车辆','当前在办'],rows:rows.map(r=>[r.label,r.reported,r.closed,r.unclosed,r.vehicles,r.open])})), + {name:'车型与部位',headers:['品牌车型','故障部位','故障数量'],rows:data.matrix.cells.filter(c=>c.count).map(c=>[c.model,c.category,c.count])}, + ]; + for(const table of tables){const sheet=book.addWorksheet(table.name);sheet.columns=table.headers.map((_,i)=>({width:i===0?32:24}));sheet.mergeCells(1,1,1,table.headers.length);sheet.getCell('A1').value=`故障风险与服务分析 · ${table.name}`;sheet.getRow(1).height=36;sheet.getCell('A1').font={size:18,bold:true,color:{argb:'FFFFFFFF'}};sheet.getCell('A1').fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF1E293B'}}; + sheet.mergeCells(2,1,2,table.headers.length);sheet.getCell('A2').value=`${data.start} 至 ${data.end} · ${filters||'全部数据权限范围'}`;sheet.getCell('A2').alignment={wrapText:true};sheet.getRow(2).height=32; + sheet.mergeCells(3,1,3,table.headers.length);sheet.getCell('A3').value=`数据时间:${new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false})}(北京时间)`; + sheet.getRow(5).values=table.headers;table.rows.forEach((r,i)=>sheet.getRow(i+6).values=r); + for(let n=5;n<=table.rows.length+5;n++){sheet.getRow(n).height=27;sheet.getRow(n).eachCell((cell,i)=>{cell.font={name:'Microsoft YaHei',size:11,bold:n===5,color:{argb:n===5?'FFFFFFFF':'FF334155'}};cell.fill={type:'pattern',pattern:'solid',fgColor:{argb:n===5?'FF2563EB':n%2?'FFF5F8FC':'FFFFFFFF'}};cell.alignment={vertical:'middle',horizontal:i===1?'left':'center'};if(typeof cell.value==='number')cell.numFmt='#,##0';});} + sheet.views=[{state:'frozen',ySplit:5,showGridLines:false}];sheet.autoFilter={from:'A5',to:{row:table.rows.length+5,column:table.headers.length}}; + } + const bytes=await book.xlsx.writeBuffer(),url=URL.createObjectURL(new Blob([new Uint8Array(bytes)],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}));const a=document.createElement('a');a.href=url;a.download=`故障风险与服务分析_${data.start}_${data.end}.xlsx`;a.click();setTimeout(()=>URL.revokeObjectURL(url),10000); +} diff --git a/src/modules/assets/faults/dashboard.css b/src/modules/assets/faults/dashboard.css new file mode 100644 index 0000000..5a7ef91 --- /dev/null +++ b/src/modules/assets/faults/dashboard.css @@ -0,0 +1,125 @@ +/* Fault analysis workspace: compact panels and a shared, explicit query scope. */ +.fd-page,.fault-overview-page { --fd-blue:#0965ff; --fd-ink:#162449; --fd-muted:#7385a4; --fd-border:#e3ebf6; padding:18px 22px 22px; background:#f8fbff; color:var(--fd-ink); font-size:12px; min-height:100vh; } +.fd-page button { cursor:pointer; }.fd-page button:disabled { cursor:default; opacity:.48; }.fd-page button:focus-visible,.fd-page select:focus-visible,.fd-page input:focus-visible,.fd-page summary:focus-visible { outline:2px solid #2563eb; outline-offset:3px; } +.fd-header { display:flex; align-items:center; justify-content:space-between; gap:18px; }.fd-breadcrumb { color:var(--fd-muted); font-size:11px; margin-bottom:10px; }.fd-heading-line { display:flex; gap:16px; align-items:center; }.fd-heading-line h1 { font-size:24px; font-weight:750; letter-spacing:-.04em; color:#081e50; white-space:nowrap; }.fd-heading-line p { font-size:12px; color:#526584; } +.fd-header-actions { display:flex; align-items:center; gap:16px; flex-shrink:0; font-size:11px; }.fd-header-actions>span { color:var(--fd-muted); }.fd-header-actions>button,.fd-header-actions .analysis-button { display:inline-flex; align-items:center; gap:6px; }.fd-header-actions .analysis-button { background:white; border:1px solid #d8e3f2; border-radius:6px; padding:9px 15px; }.fd-header-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; }.fd-header-actions small { color:#dc2626; } +.fd-tabs { display:flex; gap:28px; border-bottom:1px solid var(--fd-border); margin:14px 0 14px; }.fd-tabs button { padding:10px 20px 9px; border-bottom:3px solid transparent; font-size:13px; font-weight:600; }.fd-tabs button[aria-current=page] { color:var(--fd-blue); border-color:var(--fd-blue); } +.fd-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }.fd-date { flex:1.6 1 275px; min-width:240px; }.fd-date>button { margin:0; padding:6px 10px; border-radius:6px; box-shadow:none; gap:8px; height:42px; }.fd-date>button>span:first-child { width:24px; height:24px; background:white; }.fd-date>button>span:nth-child(2) { display:flex; align-items:center; gap:10px; }.fd-date>button>span:nth-child(2)>span:first-child { font-size:10px; }.fd-date>button>span:nth-child(2)>span:nth-child(2) { margin:0; font-size:11px; font-weight:500; }.fd-date>button>span:nth-child(3) { display:none; } +.fd-filter { display:flex; align-items:center; gap:10px; flex:1 1 170px; min-width:0; }.fd-filter>span { color:#687e9f; font-size:11px; white-space:nowrap; }.fd-filter select { border:1px solid #d8e3f2; height:36px; border-radius:5px; min-width:0; flex:1; padding:0 10px; background:#fff; font-size:11px; } +.fd-filter-toggle { display:flex; align-items:center; justify-content:center; gap:8px; border:1px solid #d8e3f2; height:36px; border-radius:5px; padding:0 13px; background:white; }.fd-filter-toggle svg { color:var(--fd-blue); }.fd-query { background:var(--fd-blue); color:white; padding:10px 27px; border-radius:5px; font-size:12px; font-weight:600; white-space:nowrap; }.fd-more-filters { flex-basis:100%; display:flex; align-items:center; flex-wrap:wrap; gap:14px; padding:12px; border:1px solid var(--fd-border); border-radius:6px; background:#fff; }.fd-more-filters>button { color:var(--fd-blue); padding:10px; }.fd-scope-note { color:#7e91ad; font-size:10px; margin:7px 0 10px; }.fd-scope-note strong { color:#c9750a; margin-left:8px; font-weight:500; } +.fd-content.is-loading { opacity:.5; pointer-events:none; }.fd-kpis { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; }.fd-kpi { background:white; border:1px solid var(--fd-border); border-radius:7px; padding:10px 14px; min-width:0; height:117px; }.fd-kpi>button { display:grid; grid-template-columns:32px 1fr 15px; align-items:center; width:100%; text-align:left; gap:7px 10px; }.fd-kpi>button>span { font-size:12px; font-weight:600; }.fd-kpi>button>svg:last-of-type { color:#8ca1c2; }.fd-kpi-icon { width:30px; height:30px; padding:6px; border-radius:8px; color:var(--fd-blue); background:#eff6ff; }.fd-kpi strong { grid-column:2/4; font-size:29px; font-weight:650; line-height:1.05; font-variant-numeric:tabular-nums; }.fd-kpi strong small { font-size:11px; font-weight:400; color:#8596b0; margin-left:8px; }.fd-kpi>p { display:flex; align-items:center; gap:8px; margin-top:12px; font-size:10px; color:#8091ac; }.fd-kpi>p b { font-size:12px; }.fd-kpi>p small { margin-left:auto; font-size:9px; }.fd-kpi-parts { display:flex; align-items:center; gap:5px; margin-top:9px; }.fd-kpi-parts>span { color:#8495af; }.fd-kpi-parts button { flex:1; display:flex; justify-content:space-between; align-items:center; background:#fff9e8; color:#ad7615; padding:6px 8px; border-radius:4px; font-size:10px; }.fd-kpi-parts button:last-child { background:#eff5ff; color:#1b65d8; }.fd-kpi-parts b { color:var(--fd-ink); font-size:13px; } +.fd-blue { color:#1764f4; }.fd-purple { color:#5645ef; background:#f2f0ff; }.fd-orange { color:#d98308; }.fd-danger { color:#dc2626!important; }.fd-green { color:#059669; }.fd-kpi-icon.fd-orange { background:#fff7e6; }.fd-kpi-icon.fd-danger { background:#fff0f2; } +.fd-today-stats { display:flex; align-items:center; gap:18px; padding:9px 2px; font-size:10px; color:#8192ad; }.fd-today-stats button { color:#647896; }.fd-today-stats b { color:#294779; margin-left:6px; font-size:11px; }.fd-insight { display:flex; align-items:center; gap:10px; background:#eef6ff; border:1px solid #c7ddff; color:#0965ff; padding:10px 13px; border-radius:6px; margin:0 0 12px; font-size:11px; }.fd-insight>svg { flex-shrink:0; }.fd-insight strong { font-weight:550; }.fd-insight>span { color:#7e94b8; font-size:10px; margin-left:auto; }.fd-insight button { display:flex; align-items:center; gap:4px; flex-shrink:0; } +.fd-grid { display:grid; gap:12px; margin-bottom:12px; }.fd-grid-primary { grid-template-columns:1.5fr 1fr; }.fd-grid-secondary { grid-template-columns:1.2fr 1fr; }.fd-panel { background:#fff; border:1px solid var(--fd-border); border-radius:7px; padding:12px; min-width:0; }.fd-panel-title { display:flex; align-items:center; flex-wrap:wrap; gap:10px; margin-bottom:11px; }.fd-panel-title h2 { font-size:14px; font-weight:700; color:#172647; border-left:4px solid #0965ff; padding-left:9px; line-height:16px; }.fd-panel-title>span { color:#7a8eac; font-size:10px; }.fd-panel-title>button { display:flex; align-items:center; gap:4px; margin-left:auto; color:var(--fd-blue); font-size:11px; }.fd-panel-title>.fd-chip { margin-left:auto; }.fd-focus .fd-panel-title>.fd-chip { margin-left:0; }.fd-chip { padding:4px 8px; border:1px solid #e5ebf4; border-radius:4px; background:#f8fafc; }.fd-chip.fd-orange { color:#ad7615; background:#fff9e9; }.fd-chip.fd-danger { background:#fff1f2; border-color:#fecdd3; } +.fd-chart-controls { display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }.fd-segment { display:flex; border-radius:4px; background:#f3f6fa; padding:2px; gap:2px; }.fd-segment button { padding:6px 12px; font-size:11px; white-space:nowrap; border-radius:3px; color:#5b7296; }.fd-segment button[aria-pressed=true] { color:white; background:var(--fd-blue); }.fd-segment button:disabled { font-size:9px; padding:6px; }.fd-legend { display:flex; gap:18px; justify-content:flex-end; margin:12px 0 2px; font-size:10px; }.fd-distribution-chart { max-height:360px; overflow:auto; }.fd-panel-foot { display:flex; justify-content:space-between; align-items:center; gap:8px; margin-top:7px; font-size:9px; color:#8296b3; }.fd-panel-foot button { color:var(--fd-blue); font-size:10px; flex-shrink:0; padding:3px; }.fd-caption { font-size:10px; color:#8396b2; margin:8px 0; line-height:1.6; }.fd-level-list { display:grid; gap:11px; padding:14px 12px; }.fd-level-list button { display:grid; grid-template-columns:68px 1fr 28px 12px; align-items:center; gap:10px; font-size:11px; text-align:left; }.fd-level-track { height:18px; }.fd-level-track>span { display:block; height:100%; min-width:2px; }.fd-level-list button>svg { color:#90a3bf; }.fd-info { display:flex; align-items:center; gap:10px; font-size:10px; line-height:1.7; border-radius:5px; padding:8px 10px; color:#286dd7; background:#eff6ff; }.fd-info svg { flex-shrink:0; } +.fd-table-scroll { overflow:auto; max-height:420px; border:1px solid #e7eef7; border-radius:5px; }.fd-page table { width:100%; border-collapse:collapse; font-size:11px; }.fd-page th { font-size:10px; font-weight:600; background:#f7f9fc; color:#637b9e; text-align:left; }.fd-page td,.fd-page th { padding:7px 9px; border-bottom:1px solid #edf2f9; }.fd-page tbody tr:last-child td,.fd-page tbody tr:last-child th { border-bottom:none; }.fd-page table button { color:#1764ef; }.fd-page table button:hover { text-decoration:underline; }.fd-matrix { min-width:620px; }.fd-matrix td,.fd-matrix thead th:not(:first-child) { text-align:center; border-left:1px solid #ecf2fa; }.fd-matrix tbody th { max-width:145px; min-width:135px; white-space:normal; background:white; color:#334a6d; }.fd-matrix td button { display:block; width:100%; min-width:38px; color:#12356e; font-weight:600; }.fd-customers { min-width:440px; }.fd-customer-meter { display:flex; align-items:center; gap:7px; width:100%; }.fd-customer-meter progress { width:110px; height:9px; appearance:none; border:none; background:transparent; border-radius:8px; }.fd-customer-meter progress::-webkit-progress-bar { background:#f0f5fc; border-radius:8px; }.fd-customer-meter progress::-webkit-progress-value { background:#2475ff; border-radius:8px; }.fd-customer-meter progress::-moz-progress-bar { background:#2475ff; border-radius:8px; }.fd-customers td:first-child { color:#8193b0; }.fd-customers td:last-child { text-align:center; } +.fd-tickets { min-width:1000px; }.fd-tickets td { line-height:1.5; }.fd-tickets small { display:block; margin-top:2px; font-size:9px; color:#7d90ab; }.fd-tickets strong { font-weight:600; }.fd-tickets td:nth-child(2) { max-width:140px; }.fd-description { max-width:250px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }.fd-grade { white-space:nowrap; font-size:10px; padding:3px 5px; border-radius:3px; color:#73859f; background:#f2f5f9; }.fd-grade-L1 { color:#dc2626; background:#fff0f1; }.fd-grade-L2 { color:#cf7b00; background:#fff7e8; }.fd-grade-L3 { color:#1264e8; background:#eff6ff; }.fd-grade-L4 { color:#5786c1; background:#eff6ff; }.fd-tickets .fd-overdue-tag { display:inline-block; color:#dc2626; background:#fff1f2; padding:1px 4px; margin-left:5px; }.fd-focus .fd-caption { margin-bottom:0; }.fd-quality { display:flex; gap:7px; align-items:center; font-size:10px; color:#8a9ab1; padding:12px 2px 0; line-height:1.7; }.fd-quality svg { flex-shrink:0; }.fd-empty { padding:28px 16px; text-align:center; color:#8798b1; }.fd-loading { display:flex; align-items:center; justify-content:center; gap:12px; padding:80px 20px; color:#8193ad; }.fd-chart-data { font-size:10px; color:#6b83a5; margin-top:6px; }.fd-chart-data summary { cursor:pointer; }.fd-chart-data[open] { max-height:260px; overflow:auto; }.fd-chart-data table { margin-top:6px; } +.fd-analysis-banner { display:flex; justify-content:space-between; gap:16px; padding:15px; background:white; border:1px solid var(--fd-border); border-radius:6px; margin-bottom:12px; }.fd-analysis-banner h2 { font-weight:600; margin-bottom:6px; }.fd-analysis-banner p { color:#7c8da8; font-size:11px; }.fd-reports>.fault-summary { margin-top:12px; border-radius:8px; border-color:var(--fd-border); padding:16px; }.fd-detail-controls { display:flex; flex-wrap:wrap; justify-content:space-between; gap:12px; margin:14px 0; }.fd-detail-controls>label { display:flex; align-items:center; gap:7px; border:1px solid var(--fd-border); padding:7px 10px; border-radius:5px; color:#90a0b7; }.fd-detail-controls input { width:220px; outline:none; color:#334155; }.fd-details .fd-table-scroll { max-height:65vh; }.fd-load-more { display:block; margin:12px auto; color:var(--fd-blue); padding:10px; } +@media(min-width:1350px) { .fd-toolbar { flex-wrap:nowrap; position:relative; }.fd-toolbar:has(.fd-more-filters) { flex-wrap:wrap; }.fd-date { flex:0 0 330px; }.fd-filter { flex:1 1 150px; }.fd-query { padding:10px 29px; } } +@media(max-width:1150px) { .fd-header { align-items:flex-start; }.fd-heading-line { display:block; }.fd-heading-line p { margin-top:7px; }.fd-header-actions { gap:10px; flex-wrap:wrap; justify-content:flex-end; max-width:260px; }.fd-kpis { gap:10px; }.fd-insight>span { display:none; }.fd-grid-primary,.fd-grid-secondary { grid-template-columns:1.2fr 1fr; }.fd-kpi>p small { display:none; } } +@media(max-width:900px) { .fd-grid-primary,.fd-grid-secondary { grid-template-columns:1fr; }.fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); }.fd-header-actions>span { display:none; }.fd-heading-line h1 { font-size:21px; } } +@media(max-width:767px) { .fd-page { padding:14px 12px 20px; }.fd-header { gap:10px; }.fd-breadcrumb { font-size:10px; margin-bottom:6px; }.fd-heading-line h1 { font-size:19px; }.fd-heading-line p { display:none; }.fd-header-actions { gap:7px; }.fd-header-actions .analysis-button { font-size:10px; padding:9px 7px; min-height:36px; }.fd-header-actions>button { font-size:0; padding:8px; }.fd-header-actions>button svg { width:16px; height:16px; }.fd-tabs { gap:0; margin:10px 0 12px; }.fd-tabs button { flex:1; padding:10px 0; font-size:12px; }.fd-toolbar { gap:8px; }.fd-date { flex-basis:100%; min-width:0; }.fd-date>button { height:40px; }.fd-date>button>span:nth-child(2) { justify-content:space-between; }.fd-filter { flex:1 1 calc(50% - 8px); gap:5px; }.fd-filter>span { font-size:10px; min-width:40px; }.fd-filter select { height:36px; padding:0 6px; font-size:11px; }.fd-toolbar>.fd-filter:nth-of-type(3) { flex-basis:100%; }.fd-filter-toggle { flex:1; }.fd-toolbar>.fd-query { flex:1; padding:10px; }.fd-scope-note { font-size:9px; line-height:1.7; }.fd-scope-note strong { display:block; margin:0; }.fd-kpis { gap:8px; }.fd-kpi { height:111px; padding:10px; }.fd-kpi>button { grid-template-columns:25px 1fr 12px; gap:7px; }.fd-kpi-icon { width:25px; height:25px; padding:5px; }.fd-kpi>button>span { font-size:11px; }.fd-kpi strong { font-size:26px; }.fd-kpi>p { font-size:9px; gap:6px; margin-top:10px; }.fd-kpi>p b { font-size:11px; }.fd-kpi-parts { margin-top:9px; gap:3px; }.fd-kpi-parts button { font-size:9px; padding:5px; }.fd-kpi-parts b { font-size:12px; }.fd-today-stats { gap:12px; justify-content:space-between; font-size:9px; }.fd-today-stats>span { display:none; }.fd-today-stats b { margin-left:4px; }.fd-insight { font-size:10px; align-items:flex-start; gap:7px; padding:9px; flex-wrap:wrap; }.fd-insight strong { flex:1; line-height:1.7; }.fd-insight button { margin-left:auto; padding:4px; }.fd-grid { gap:10px; margin-bottom:10px; }.fd-panel { padding:10px; }.fd-panel-title h2 { font-size:13px; }.fd-panel-title { gap:7px; }.fd-panel-title>span { font-size:9px; }.fd-chart-controls { gap:8px; }.fd-segment button { padding:7px 9px; font-size:10px; }.fd-level-list { padding:12px 6px; }.fd-level-list button { min-height:28px; }.fd-panel-foot { font-size:9px; }.fd-focus .fd-chip { font-size:9px; padding:3px 5px; }.fd-focus .fd-panel-title>button { font-size:10px; }.fd-analysis-banner { flex-direction:column; }.fd-quality { font-size:9px; align-items:flex-start; }.fd-detail-controls>label { width:100%; }.fd-detail-controls input { width:100%; }.fd-more-filters { gap:9px; padding:9px; }.fd-more-filters .fd-filter { flex-basis:100%; }.fd-page .fault-analysis-dialog { font-size:12px; } } +@media(prefers-reduced-motion:reduce) { .fd-page * { scroll-behavior:auto; } } +/* The workspace owns its breadcrumb, so the shell's fixed breadcrumb must not cover it. */ +.app-shell-content:has(>.fd-page)>.fixed.top-0 { display:none; } +.fd-breadcrumb { margin-bottom:6px; line-height:16px; }.fd-heading-line h1 { line-height:1.25; }.fd-tabs { margin-top:10px; margin-bottom:10px; } +.fd-date>button>span:nth-child(2)>span:first-child { display:none; }.fd-date>button>span:nth-child(2)>span:nth-child(2) { font-size:12px; gap:14px; } +.fd-focus td { padding-top:6px; padding-bottom:6px; } +@media(max-width:360px) { + .fd-header { flex-wrap:wrap; gap:8px; } + .fd-header-actions { width:100%; justify-content:flex-end; } + .fd-kpi>button { grid-template-columns:21px 1fr 10px; gap:6px 4px; } + .fd-kpi-icon { width:21px; height:21px; padding:4px; } + .fd-kpi>button>span { font-size:10px; } +} +@media(max-width:360px) { + .fd-toolbar>.fd-filter { flex-basis:100%; } + .fd-toolbar>.fd-filter>span { min-width:42px; } +} +@media(min-width:901px) { + .fd-grid-primary .fd-panel-title { margin-bottom:8px; } + .fd-grid-primary .fd-caption { margin:5px 0; } + .fd-level-list { gap:8px; padding:8px 12px; } + .fd-grid-primary .fd-info { padding:6px 10px; } + .fd-grid-secondary td,.fd-grid-secondary th { padding:4px 9px; } +} + +/* Overview shares the analysis palette while preserving its statistics and report layout. */ +.fault-overview-page button { cursor:pointer; } +.fault-overview-page button:disabled { cursor:default; } +.fault-overview-page button:focus-visible { outline:2px solid var(--fd-blue); outline-offset:3px; } +.fault-overview-page>.fault-summary { + padding:16px; border-color:var(--fd-border); border-radius:7px; box-shadow:none; +} +.fault-overview-page>.fault-trend { margin-top:12px; } +.fault-overview-page .fault-summary-heading { margin-bottom:14px; } +.fault-overview-page .fault-summary-heading h2, +.fault-overview-page .fault-trend-heading h2 { + border-left:4px solid var(--fd-blue); padding-left:9px; color:#172647; + font-size:14px; font-weight:700; line-height:16px; +} +.fault-overview-page .fault-trend-heading h2>svg { display:none; } +.fault-overview-page .fault-summary-heading>span, +.fault-overview-page .fault-trend-definition, +.fault-overview-page .fault-summary-note { color:var(--fd-muted); } +.fault-overview-page .fault-metric { border-color:var(--fd-border); border-radius:7px; } +.fault-overview-page .fault-metric-main { border-radius:7px; } +.fault-overview-page .fault-metric:hover { border-color:#93c5fd; box-shadow:0 3px 10px rgb(9 101 255 / .05); } +.fault-overview-page .fault-metric-label { color:#334a6d; font-weight:600; } +.fault-overview-page .fault-metric-count strong { color:var(--fd-ink); } +.fault-overview-page .fault-metric p { color:var(--fd-muted); } +.fault-overview-page .fault-metric-link { color:var(--fd-blue); } +.fault-overview-page .fault-metric-icon { color:var(--fd-blue); background:#eff6ff; } +.fault-overview-page .fault-metric-closed .fault-metric-icon { color:#059669; background:#ecfdf5; } +.fault-overview-page .fault-metric-open .fault-metric-icon { color:#d98308; background:#fff7e6; } +.fault-overview-page .fault-metric-ledger .fault-metric-icon { color:#5645ef; background:#f2f0ff; } +.fault-overview-page .fault-open-breakdown { border-color:var(--fd-border); } +.fault-overview-page .fault-submetric { border-radius:4px; } +.fault-overview-page .fault-submetric-waiting { color:#ad7615; background:#fff9e8; } +.fault-overview-page .fault-submetric-processing { color:#1b65d8; background:#eff5ff; } +.fault-overview-page .fault-submetric strong { color:var(--fd-ink); } +.fault-overview-page .fault-overdue-subset { border-color:#fecdd3; border-radius:4px; background:#fff1f2; } +.fault-overview-page .fault-report-actions .analysis-export-action button { + border-color:#d8e3f2; border-radius:6px; color:#526584; box-shadow:none; +} +.fault-overview-page .fault-trend-heading [role=group] { border-radius:4px; background:#f3f6fa; padding:2px; gap:2px; } +.fault-overview-page .fault-trend-heading [role=group] button { border-radius:3px; color:#5b7296; } +.fault-overview-page .fault-trend-heading button[aria-pressed=true] { color:white; background:var(--fd-blue); box-shadow:none; } +.fault-overview-page [data-testid=asset-flow-date-range] { + border-color:#d8e3f2; border-radius:6px; padding:10px 12px; box-shadow:none; +} +.fault-overview-page [data-testid=asset-flow-date-range]>span:first-child { width:30px; height:30px; border-radius:6px; color:var(--fd-blue); background:#eff6ff; } +.fault-overview-page [data-testid=asset-flow-date-range]>span:nth-child(3) { border-radius:4px; color:#687e9f; background:#f3f6fa; } +.fault-overview-page .fault-trend-totals { border-color:var(--fd-border); border-radius:6px; background:#f7faff; } +.fault-overview-page .fault-trend-totals button { border-color:var(--fd-border); } +.fault-overview-page .fault-trend-all, +.fault-overview-page .fault-trend-closed { color:#1764f4; } +.fault-overview-page .fault-trend-unclosed { color:#d98308; } +.fault-overview-page .fault-trend-expand { border-color:#c7ddff; border-radius:6px; color:var(--fd-blue); background:#eef6ff; } +.fault-overview-page .fault-weekly-chart { border-color:var(--fd-border); border-radius:7px; } +.fault-overview-page .fault-trend-table { border-color:var(--fd-border); border-radius:5px; } +.fault-overview-page .fault-trend-table th { color:#637b9e; background:#f7f9fc; } +.fault-overview-page .fault-trend-table td { border-color:#edf2f9; } +@media(max-width:767px) { + .fault-overview-page { padding:14px 12px 20px; } + .fault-overview-page>.fault-summary { padding:12px; } + .fault-overview-page .fault-summary-heading h2, + .fault-overview-page .fault-trend-heading h2 { font-size:13px; } +} +.app-shell-content:has(>.fault-overview-page)>.fixed.top-0 { display:none; } +.fd-kpis-five { grid-template-columns:repeat(5,minmax(0,1fr)); } +.fd-kpis-five>.fd-kpi { display:flex; flex-direction:column; } +.fd-kpis-five .fd-kpi>button { flex-shrink:0; } +.fd-kpis-five .fd-kpi>p, +.fd-kpis-five .fd-kpi-parts { margin-top:auto; min-height:24px; } +.fd-kpis-five .fd-kpi>p { align-items:center; } +.fd-kpis-five .fd-kpi-parts { gap:5px; } +.fd-kpis-five .fd-kpi-parts button { + height:24px; min-width:0; padding:3px 7px; line-height:18px; white-space:nowrap; +} +.fd-kpis-five .fd-kpi-parts b { line-height:18px; } +.fd-kpis-five .fd-kpi-parts>span { line-height:24px; font-size:12px; } +@media(max-width:1100px) { + .fd-kpis-five { grid-template-columns:repeat(2,minmax(0,1fr)); } + .fd-kpis-five>.fd-kpi:last-child { grid-column:1/-1; } +} diff --git a/src/modules/assets/faults/report-export.ts b/src/modules/assets/faults/report-export.ts index 3b39932..e3d13a2 100644 --- a/src/modules/assets/faults/report-export.ts +++ b/src/modules/assets/faults/report-export.ts @@ -1,7 +1,7 @@ import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults'; import type { Cell, Worksheet } from 'exceljs'; -export type FaultReport = { kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }; +export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string }; interface ReportChartImage { dataUrl: string; width: number; height: number } const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' }; const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } }); @@ -39,6 +39,7 @@ export async function createFaultReportWorkbook(report: FaultReport, chartImage? merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`); const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10; + if (report.filterLabel) { const filter = merged(sheet, 4, 1, columns, `筛选条件:${report.filterLabel}`); filter.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; filter.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(4).height = 32; } const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0'; const cards = trend ? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }] diff --git a/src/server/routes/asset-faults/dashboard.test.ts b/src/server/routes/asset-faults/dashboard.test.ts new file mode 100644 index 0000000..f859404 --- /dev/null +++ b/src/server/routes/asset-faults/dashboard.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Hono } from 'hono'; +import { mapFault } from './model.js'; +import { faultDashboard, dashboardAnalysis, filterDashboardRows } from './dashboard.js'; +import { createFaultRouter } from './index.js'; +import type { AuthUser } from '../../auth/types.js'; +const updatedAt='2026-10-13T08:00:00Z'; +const q={start:'2026-10-12',end:'2026-10-13'}; +const fault=(id:string,extra:Record={})=>mapFault({id,fault_code:`F${id}`,matched_vehicle_id:'v1',plate_number:'沪A001',model_name:'车型A',province_name:'上海市',customer_name:'客户A',business_dept:'部门A',reported_at:'2026-10-13 08:00:00',handling_status:'待处理',fault_level:1,fault_categories:'底盘故障,三电故障',...extra},new Map()); +const rows=[fault('1'),fault('2',{handling_status:'已归档',archived_at:'2026-10-13 12:00:00'}),fault('3',{reported_at:'2026-10-01 08:00:00',handling_status:'处理中',deadline_at:'2026-10-12 15:00:00',matched_vehicle_id:'v2'}),fault('4',{matched_vehicle_id:null,plate_number:null,fault_level:null,fault_categories:null,customer_name:null}),fault('5',{reported_at:'2026-10-10 08:00:00',handling_status:'挂起'})]; +test('dashboard separates reporting cohort from all-date current workload, preserves unknowns and deduplicates vehicles',()=>{ + const d=faultDashboard(rows,q,updatedAt); + assert.equal(d.totals.reported,3);assert.equal(d.totals.vehicles,1);assert.equal(d.totals.customers,1); + assert.equal(d.totals.open,3);assert.equal(d.totals.waiting+d.totals.processing,d.totals.open);assert.equal(d.totals.overdue,1);assert.equal(d.totals.longestOverdueDays,2); + assert.equal(d.totals.previous,1);assert.equal(d.totals.change,200); + assert.equal(d.quality.missingLevel,1);assert.equal(d.quality.missingVehicle,1);assert.equal(d.quality.missingCategory,1); + assert.equal(d.focus[0].id,'3');assert.equal(d.distributions.region.reduce((n,r)=>n+r.reported,0),3); + const m=rows[0].assetCell.model;assert.equal(d.matrix.totals[m],3);assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='底盘故障')?.count,2); + assert.equal(d.matrix.cells.find(c=>c.model===m&&c.category==='三电故障')?.count,2); +}); +test('dashboard drill scope and all filters reconcile to visible aggregates without leaking internal fields',()=>{ + const d=faultDashboard(rows,q,updatedAt);const group=d.distributions.customer.find(r=>r.label!=='未关联客户')!; + const details=dashboardAnalysis(rows,{...q,customer:group.value},updatedAt); + assert.equal(details.rows.length,group.reported);assert.ok(details.rows.every(r=>!('vehicleKey'in r)&&!('managerId'in r))); + assert.deepEqual(dashboardAnalysis(rows,{...q,scope:'overdue'},updatedAt).rows.map(r=>r.id),['3']); + assert.equal(dashboardAnalysis(rows,{...q,scope:'ledger',id:'5'},updatedAt).rows.length,1); + assert.equal(filterDashboardRows(rows,{level:'未标注',category:'未标注'}).length,1); + assert.equal(dashboardAnalysis(rows,{...q,scope:'cohort',level:'未标注'},updatedAt).rows.length,1); + const empty=faultDashboard(rows,{...q,region:'不存在'},updatedAt);assert.equal(empty.totals.reported,0);assert.equal(empty.totals.change,null);assert.equal(empty.totals.longestOverdueDays,0); +}); +test('dashboard and report endpoints enforce authentication, permissions, dates and shared filters',async()=>{ + let loads=0;const router=createFaultRouter(async()=>{loads++;return{rows,updatedAt};});const anonymous=new Hono();anonymous.route('/f',router); + assert.equal((await anonymous.request('/f/dashboard')).status,401);assert.equal(loads,0); + const user:AuthUser={userId:'test',loginName:'test',userName:'测试',roles:[],permissionLevel:'department',depName:'部门A',depCode:'a'}; + const app=new Hono<{Variables:{user:AuthUser}}>();app.use('*',async(c,next)=>{c.set('user',user);await next();});app.route('/f',router); + assert.equal((await app.request('/f/dashboard?start=bad')).status,400);assert.equal(loads,0); + assert.equal((await app.request('/f/dashboard-analysis?scope=unknown')).status,400);assert.equal(loads,0); + const report=await(await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json();assert.equal(report.totals.reported,2);assert.equal(report.options.level.some((o:{value:string})=>o.value==='未标注'),false); + const trend=await(await app.request('/f/trend?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(trend.totals.reported,0); + const weekly=await(await app.request('/f/weekly-report?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(weekly.totals.reported,0); +}); diff --git a/src/server/routes/asset-faults/dashboard.ts b/src/server/routes/asset-faults/dashboard.ts new file mode 100644 index 0000000..3e817b5 --- /dev/null +++ b/src/server/routes/asset-faults/dashboard.ts @@ -0,0 +1,67 @@ +import { FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js'; +import { faultAnalysis, faultOverview, isOverdueFault, type ScopedFault } from './model.js'; +import { parseFaultTrendQuery, selectFaultCohort } from './trend.js'; + +export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension): string[] { + if (key === 'handler') return row.handlers.map(h => h.value); + if (key === 'level') return [row.level || '未标注']; + if (key === 'category') return row.categories?.length ? row.categories : ['未标注']; + if (key === 'inventory') return [row.inventory]; + return [row.assetCell[key]]; +} +function label(row: ScopedFault, key: FaultDashboardDimension, value: string) { + return key === 'customer' ? row.customerLabel : key === 'handler' ? row.handlers.find(h => h.value === value)?.label || value : value; +} +export function filterDashboardRows(rows: ScopedFault[], params: Record) { + return rows.filter(row => (Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).every(key => !params[key] || dashboardValues(row, key).includes(params[key]))); +} +export function parseDashboardQuery(params: Record) { + const query = parseFaultTrendQuery({ start: params.start, end: params.end }); + const scope = params.scope || 'cohort'; + if (!['cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围'); + return { ...query, scope: scope as FaultDashboardScope }; +} +export function dashboardAnalysis(rows: ScopedFault[], params: Record, updatedAt: string) { + const query = parseDashboardQuery(params); + let selected = filterDashboardRows(rows, params); + if (params.id) selected = selected.filter(row => row.id === params.id); + if (query.scope === 'cohort') selected = selectFaultCohort(selected, query); + return faultAnalysis(selected, query.scope === 'cohort' ? 'ledger' : query.scope, updatedAt); +} +export function faultDashboard(visible: ScopedFault[], params: Record, updatedAt: string): FaultDashboardResponse { + const query = parseDashboardQuery(params); + const all = filterDashboardRows(visible, params); + const cohort = selectFaultCohort(all, query); + const overview = faultOverview(all, updatedAt).totals; + const open = all.filter(r => ['待处理', '处理中'].includes(r.status)); + const overdue = open.filter(r => isOverdueFault(r, updatedAt)); + const vehicles = (rows: ScopedFault[]) => new Set(rows.map(r => r.vehicleKey).filter(Boolean)).size; + const options = Object.fromEntries((Object.keys(FAULT_DASHBOARD_FILTERS) as FaultDashboardDimension[]).map(key => [key, + [...new Map(visible.flatMap(row => dashboardValues(row, key).map(value => [value, { value, label: label(row, key, value) }] as const))).values()].sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')), + ])) as FaultDashboardResponse['options']; + function groups(key: 'region' | 'level' | 'customer' | 'model') { + const buckets = new Map(); + cohort.forEach(row => { const value = dashboardValues(row, key)[0]; const group = buckets.get(value) || []; group.push(row); buckets.set(value, group); }); + return [...buckets].map(([value, rows]) => ({ value, label: label(rows[0], key, value), reported: rows.length, + closed: rows.filter(r => r.status === '已归档').length, unclosed: rows.filter(r => r.status !== '已归档').length, + vehicles: vehicles(rows), open: open.filter(r => dashboardValues(r, key).includes(value)).length, + })).sort((a, b) => b.reported - a.reported || a.label.localeCompare(b.label, 'zh-CN')); + } + const distributions = { region: groups('region'), level: groups('level'), customer: groups('customer'), model: groups('model') }; + const categoryKeys = [...new Set(cohort.flatMap(row => dashboardValues(row, 'category')))].sort((a,b)=>a.localeCompare(b,'zh-CN')); + const matrix = { models: distributions.model.map(r => ({value:r.value,label:r.label})), categories: categoryKeys.map(value => ({value,label:value})), + cells: distributions.model.flatMap(model => categoryKeys.map(category => ({model:model.value,category,count:cohort.filter(r=>r.assetCell.model===model.value&&dashboardValues(r,'category').includes(category)).length}))), + totals: Object.fromEntries(distributions.model.map(r=>[r.value,r.reported])), + }; + const duration = Date.parse(query.end) - Date.parse(query.start) + 86400000; + const previousStart = new Date(Date.parse(query.start) - duration).toISOString().slice(0,10); + const previous = all.filter(r => r.reportedAt && r.reportedAt.slice(0,10) >= previousStart && r.reportedAt.slice(0,10) < query.start).length; + const focus = faultAnalysis([...open].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows; + return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus, + totals: { reported:cohort.length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size, + previous,change:previous ? (cohort.length-previous)/previous*100 : null,open:overview.open,waiting:overview.waiting,processing:overview.processing,overdue:overview.overdue, + longestOverdueDays:Math.max(0,...overdue.map(r=>Math.ceil((Date.parse(updatedAt)-Date.parse(`${r.deadlineAt!.replace(' ','T')}+08:00`))/86400000))), + todayReported:overview.reported,todayClosed:overview.closed,ledger:all.length }, + quality:{missingLevel:cohort.filter(r=>!r.level||r.level==='未标注').length,missingCategory:cohort.filter(r=>!r.categories?.length).length,missingVehicle:cohort.filter(r=>!r.vehicleKey).length,missingDeadline:open.filter(r=>!r.deadlineAt).length,missingReport:all.filter(r=>!r.reportedAt).length}, + }; +} diff --git a/src/server/routes/asset-faults/index.ts b/src/server/routes/asset-faults/index.ts index 834b85b..4b1b444 100644 --- a/src/server/routes/asset-faults/index.ts +++ b/src/server/routes/asset-faults/index.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { dashboardAnalysis, faultDashboard, filterDashboardRows, parseDashboardQuery } from './dashboard.js'; import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js'; import type { AuthUser } from '../../auth/types.js'; import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.js'; @@ -8,6 +9,18 @@ import { faultTrend, faultTrendAnalysis, parseFaultTrendQuery } from './trend.js export function createFaultRouter(load = loadFaults) { const app = new Hono<{ Variables: { user: AuthUser } }>(); + for (const path of ['/dashboard', '/dashboard-analysis'] as const) { + app.get(path, async c => { + const user = c.get('user'); + if (!user) return c.json({ error: 'Unauthorized' }, 401); + const params = c.req.query(); + try { parseDashboardQuery(params); } catch(error) { return c.json({error:(error as Error).message},400); } + try { + const data = await load(); const rows = visibleFaults(data.rows,user); c.header('Cache-Control','no-store'); + return c.json(path === '/dashboard' ? faultDashboard(rows,params,data.updatedAt) : dashboardAnalysis(rows,params,data.updatedAt)); + } catch { return c.json({error:'故障分析暂时不可用,请稍后重试'},503); } + }); + } for (const path of ['/trend', '/trend-analysis'] as const) { app.get(path, async c => { const user = c.get('user'); @@ -17,7 +30,7 @@ export function createFaultRouter(load = loadFaults) { catch (error) { return c.json({ error: (error as Error).message }, 400); } try { const data = await load(); - const rows = visibleFaults(data.rows, user); + const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query()); c.header('Cache-Control', 'no-store'); return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt)); } catch (error) { @@ -35,7 +48,7 @@ export function createFaultRouter(load = loadFaults) { catch (error) { return c.json({ error: (error as Error).message }, 400); } try { const data = await load(); - const rows = visibleFaults(data.rows, user); + const rows = filterDashboardRows(visibleFaults(data.rows, user), c.req.query()); c.header('Cache-Control', 'no-store'); return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt)); } catch (error) { diff --git a/src/server/routes/asset-faults/model.ts b/src/server/routes/asset-faults/model.ts index 53b0cbc..b9bc15a 100644 --- a/src/server/routes/asset-faults/model.ts +++ b/src/server/routes/asset-faults/model.ts @@ -9,6 +9,7 @@ export type RawFault = Record; export interface ScopedFault extends FaultRecord { subject: string; departmentName: string; managerId: string; missingVehicle: boolean; assetCell: AssetAnalysisCell; + vehicleKey?: string; } export function normalizeFaultStatus(value: unknown): string { const status = String(value ?? '').trim(); @@ -77,6 +78,10 @@ export function mapFault(row: RawFault, directory: Map): ScopedF manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1, }; return { + vehicleKey: text(row.matched_vehicle_id) || (text(row.plate_number) ? `plate:${text(row.plate_number)}` : ''), + level: text(row.level_label) || ({ '1': 'L1-特急', '2': 'L2-紧急', '3': 'L3-一般', '4': 'L4-提示' } as Record)[text(row.fault_level)] || '未标注', + categories: [...new Set(text(row.fault_categories).split(/[,,、;;]/).map(v => v.trim()).filter(Boolean))], + progress: text(row.solve_process), id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆', description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null, city, status: normalizeFaultStatus(row.handling_status), assetCell, @@ -148,7 +153,7 @@ export function analyzeFaults(visible: ScopedFault[], query: FaultQuery, updated quality: { missingReport: rows.filter(r => !r.reportedAt).length, invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length, missingVehicle: rows.filter(r => r.missingVehicle).length }, - details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => row), total: details.length, page, pageSize }, + details: { rows: details.slice((page - 1) * pageSize, page * pageSize).map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => row), total: details.length, page, pageSize }, }; } @@ -187,5 +192,5 @@ export function faultAnalysis(rows: ScopedFault[], metric: import('../../../shar const date = faultBusinessDate(updatedAt); const selected = selectFaultMetric(rows, metric, date, updatedAt); return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])), - rows: selected.map(({ subject, departmentName, managerId, missingVehicle, assetCell, ...row }) => ({ ...row, cell: assetCell })) }; + rows: selected.map(({ subject, departmentName, managerId, missingVehicle, vehicleKey, assetCell, ...row }) => ({ ...row, cell: assetCell })) }; } diff --git a/src/server/routes/asset-faults/repository.ts b/src/server/routes/asset-faults/repository.ts index e64e808..15477f1 100644 --- a/src/server/routes/asset-faults/repository.ts +++ b/src/server/routes/asset-faults/repository.ts @@ -4,6 +4,7 @@ import { mapFault, type RawFault } from './model.js'; // OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause. export const FAULT_SQL = `SELECT + f.fault_level, f.fault_categories, f.solve_process, CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status, DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at, DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at, diff --git a/src/shared/assets/faults.ts b/src/shared/assets/faults.ts index 4af5a44..1b56219 100644 --- a/src/shared/assets/faults.ts +++ b/src/shared/assets/faults.ts @@ -14,6 +14,7 @@ export interface FaultRecord { city: string; status: string; inventory: string; customer: string; customerLabel: string; department: string; handlers: FaultOption[]; + level?: string; categories?: string[]; progress?: string; } export interface FaultQuery { start: string; end: string; groupBy: FaultDimension; @@ -63,3 +64,18 @@ export interface FaultWeeklyResponse { totals: Record; days: (Record & { date: string })[]; } + +export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态' } as const; +export type FaultDashboardDimension = keyof typeof FAULT_DASHBOARD_FILTERS; +export type FaultDashboardFilters = Partial>; +export type FaultDashboardScope = 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed'; +export interface FaultDistribution { value: string; label: string; reported: number; closed: number; unclosed: number; vehicles: number; open: number } +export interface FaultDashboardResponse { + start: string; end: string; updatedAt: string; + totals: { reported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number }; + options: Record; + distributions: Record<'region' | 'level' | 'customer' | 'model', FaultDistribution[]>; + matrix: { models: FaultOption[]; categories: FaultOption[]; cells: { model: string; category: string; count: number }[]; totals: Record }; + focus: FaultAnalysisRecord[]; + quality: { missingLevel: number; missingCategory: number; missingVehicle: number; missingDeadline: number; missingReport: number }; +} From 1674f052d06de9b754a08299c1743a78dbb78ff7 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Fri, 16 Oct 2026 18:15:44 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(assets):=20=E5=AE=8C=E5=96=84=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E6=8F=90=E6=8A=A5=E6=9D=A5=E6=BA=90=E4=B8=8E=E9=87=8D?= =?UTF-8?q?=E7=82=B9=E8=B7=9F=E8=BF=9B=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/asset-fault-management.md | 8 +- src/modules/assets/faults/DashboardPanels.tsx | 4 +- src/modules/assets/faults/FaultManagement.tsx | 20 ++-- src/modules/assets/faults/FaultOverview.tsx | 6 +- src/modules/assets/faults/dashboard-export.ts | 2 +- src/modules/assets/faults/dashboard.css | 32 ++++--- .../routes/asset-faults/dashboard.test.ts | 94 +++++++++++++++++++ src/server/routes/asset-faults/dashboard.ts | 20 ++-- src/server/routes/asset-faults/model.ts | 11 ++- src/server/routes/asset-faults/repository.ts | 3 +- src/shared/assets/faults.ts | 11 ++- 11 files changed, 166 insertions(+), 45 deletions(-) diff --git a/docs/asset-fault-management.md b/docs/asset-fault-management.md index 3c075b9..8d9d7e0 100644 --- a/docs/asset-fault-management.md +++ b/docs/asset-fault-management.md @@ -11,7 +11,7 @@ | 当前在办 | 全部待处理、处理中故障,不限制提报日期;按最新确认,在办 = 待处理 + 处理中 | | 累计台账 | 全部有效故障台账,包括归档、未闭环和其他状态 | -「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“其中逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 +「当前在办」卡片以“待处理 + 处理中”展示构成,逾期以总量旁的“逾期”独立标记,均使用故障多维分析与故障明细下钻。按最新确认,在办仅为待处理与处理中之和,挂起保留在累计台账;旧兼容接口的全部未闭环口径保持不变。 - 待处理/处理中:按 OneOS 标准化任务状态分别统计,兼容待处置、未处理、处置中等历史值。 - 逾期:待处理或处理中,且保存的 `deadline_at` 严格早于本次数据时间。按北京时间解释截止时间,挂起和已归档不计入;无截止时间不自行以提报时间或固定 30 天推算。与 OneOS `selectSupervisorRows.overdueCount` 汇总口径一致,存储的截止时间包含业务端 SLA 配置及挂起顺延结果。 @@ -84,18 +84,18 @@ 故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。 -- 期间新增故障:所选提报日期内的有效故障。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 +- 期间新增故障:所选提报日期内的有效故障。卡片增加“运维提报”和“AI提报”数量及下钻;两者之和等于期间新增,沿用日期、权限及业务筛选。来源优先读取 `report_method`:运维上报显示“运维提报”,客户上报显示“AI提报”;旧数据未标明有效上报方式时,有真实 `source_work_order_id` 关联归客户上报,否则归运维上报,与 OneOS 历史展示逻辑一致。该分类不使用 `fault_source` 故障来源字典,也不推断所有客户上报均由 AI 自动创建。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 - 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。 - 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。 - 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。 - 客户影响表:本期影响车辆去重;同一客户的当前在办不限提报日期。库存空客户单列库存车。仅展示本期存在故障的客户组。 -- 重点跟进:当前在办中优先展示逾期,同优先级按提报时间倒序。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。 +- 重点跟进:只展示 L1-特急且未闭环(状态非已归档,含挂起等)的故障,沿用权限及业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示5单;统计标签使用同一 L1 未闭环范围,没有符合条件的故障时显示空态。“查看全部”始终可用,进入当前权限内的全部故障台账,清除业务筛选和搜索条件,不限等级、状态及提报日期。完整明细可切换本期、在办、逾期及累计台账,搜索车牌、单号、描述或运维处置人。 新增只读字段:`fault_level`、`fault_categories`、`solve_process`。等级映射经 OneOS 字典核对:1=L1-特急、2=L2-紧急、3=L3-一般、4=L4-提示;缺失等级/部位独立显示未标注。处置记录使用 `solve_process`,不将 AI 流转字段 `process_status` 当作业务处理进度。没有同期服务车辆规模、实际停运和修复时长依据,发生频率禁用并提示缺少分母,停运和修复时长提示待补数据。 新增接口: - `GET /api/asset-faults/dashboard?start=YYYY-MM-DD&end=YYYY-MM-DD`:授权范围内汇总、筛选项、分布、交叉矩阵、重点跟进和完整性信息。 -- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=cohort|open|waiting|processing|overdue|ledger|reported|closed`。cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。 +- `GET /api/asset-faults/dashboard-analysis`:同样日期和业务筛选;`scope=focus|cohort|open|waiting|processing|overdue|ledger|reported|closed`。focus 为全部提报日期的 L1 未闭环故障;cohort 为提报日期队列,reported/closed 为北京时间今日,其他为全部日期当前状态;可附 `id` 查看单条记录。 - 趋势、周报及其下钻接口同时支持上述业务筛选。所有接口先执行权限筛选,筛选项也仅来自授权记录,保持 no-store,不写业务数据库。 总览导出新增 6 张 Excel 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。 diff --git a/src/modules/assets/faults/DashboardPanels.tsx b/src/modules/assets/faults/DashboardPanels.tsx index 91f826b..6c80ac8 100644 --- a/src/modules/assets/faults/DashboardPanels.tsx +++ b/src/modules/assets/faults/DashboardPanels.tsx @@ -37,6 +37,6 @@ export function DashboardPanels({data, drill}:{data:FaultDashboardResponse;drill
; } -export function DashboardTicketTable({rows,updatedAt,drill}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill}) { - return
{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)})}
车辆 / 故障单号运维处置人区域 / 客户等级当前状态处置记录 / 故障描述提报时间操作
{row.plate}{row.code}{row.handlers.map(h=>h.label).join('、')}{row.cell.region}{row.customerLabel}{row.level||'未标注'}{row.status==='已归档'?'已闭环':row.status}{overdue&&已逾期}{row.progress||row.description||'暂无处置记录'}{row.reportedAt||'未记录'}
{!rows.length&&

当前条件下暂无故障。

}
; +export function DashboardTicketTable({rows,updatedAt,drill,emptyMessage='当前条件下暂无故障。'}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill;emptyMessage?:string}) { + return
{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)})}
车辆 / 故障单号运维处置人区域 / 客户等级当前状态处置记录 / 故障描述提报时间操作
{row.plate}{row.code}{row.handlers.map(h=>h.label).join('、')}{row.cell.region}{row.customerLabel}{row.level||'未标注'}{row.status==='已归档'?'已闭环':row.status}{overdue&&已逾期}{row.progress||row.description||'暂无处置记录'}{row.reportedAt||'未记录'}
{!rows.length&&

{emptyMessage}

}
; } diff --git a/src/modules/assets/faults/FaultManagement.tsx b/src/modules/assets/faults/FaultManagement.tsx index 4f0427d..528a362 100644 --- a/src/modules/assets/faults/FaultManagement.tsx +++ b/src/modules/assets/faults/FaultManagement.tsx @@ -24,13 +24,14 @@ export default function FaultManagement(){ const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString(); useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson(`/api/asset-faults/dashboard?${query}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setData(d);}).catch(e=>{if(!c.signal.aborted)setError(e instanceof Error?e.message:'加载失败');}).finally(()=>{if(!c.signal.aborted)setLoading(false);});return()=>c.abort();},[query,revision,tab]); useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson(`/api/asset-faults/dashboard-analysis?${query}&scope=${detailScope}`,{signal:c.signal}).then(d=>{if(!c.signal.aborted)setDetailData(d);}).catch(()=>{if(!c.signal.aborted)setDetailError('故障明细加载失败,请重试。');}).finally(()=>{if(!c.signal.aborted)setDetailLoading(false);});return()=>c.abort();},[tab,query,detailScope,revision]); - const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});}; + const drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='focus'?'仅包含 L1-特急且未归档的故障(含待处理、处理中、挂起等状态),不限制提报日期。':scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});}; const t=data?.totals; const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true})); const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters); const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—'; const select=(key:FaultDashboardDimension)=>; const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');}; + const showAllFaults=()=>{setFilters({});setApplied({...range,filters:{}});setDetailScope('ledger');setTab('故障明细');setSearch('');}; const navigation=; if(tab==='管理总览')return ; return
@@ -41,12 +42,11 @@ export default function FaultManagement(){ {error&&
{error}
} {!data&&loading?
正在汇总故障分析…
:data&&
{tab==='多维分析'&&<> -
-

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+
+

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+

涉及 {t?.customers} 家已关联客户按车辆去重

+
-

最长逾期 {t?.longestOverdueDays} 天在办子集

-

全部有效故障台账不限提报日期

+

最长逾期 {t?.longestOverdueDays} 天在办子集

实时统计
{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}按数量描述,不代表故障发生率。
@@ -55,12 +55,12 @@ export default function FaultManagement(){

重点故障跟进

- 当前在办 {t?.open} - 其中逾期 {t?.overdue} - + L1 未闭环 {data.focusTotals.total} + 逾期 {data.focusTotals.overdue} +
- -

沿用当前业务筛选,不限制提报日期;优先展示逾期故障,同优先级按提报时间倒序,最多展示 5 单。完整明细见“查看全部”。

+ +

仅展示 L1-特急且未闭环(未归档)的故障;沿用当前业务筛选,不限制提报日期。逾期优先,同优先级按提报时间倒序,最多展示 5 单。“查看全部”进入当前权限内的全部故障台账,并清除业务筛选和搜索条件,不限等级、状态及提报日期。

} {tab==='故障明细'&&

故障明细

按提报时间倒序 · {detailRows.length} 单
{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=>)}
{detailLoading?

正在加载故障明细…

:detailError?

{detailError}

:<>{detailRows.length>limit&&}}
} diff --git a/src/modules/assets/faults/FaultOverview.tsx b/src/modules/assets/faults/FaultOverview.tsx index 617a435..92a080e 100644 --- a/src/modules/assets/faults/FaultOverview.tsx +++ b/src/modules/assets/faults/FaultOverview.tsx @@ -46,8 +46,8 @@ export function FaultOverview({ navigation }: { navigation: ReactNode }) {

{FAULT_DEFINITIONS[key]}

查看分析与明细 → {key === 'open' && <> -
{(['waiting', 'processing'] as const).map((part, index) => @@ -60,7 +60,7 @@ export function FaultOverview({ navigation }: { navigation: ReactNode }) { } ; })}
-

当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

+

当前在办 = 待处理 + 处理中,逾期为在办子集,不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。

diff --git a/src/modules/assets/faults/dashboard-export.ts b/src/modules/assets/faults/dashboard-export.ts index 82291b0..3931747 100644 --- a/src/modules/assets/faults/dashboard-export.ts +++ b/src/modules/assets/faults/dashboard-export.ts @@ -2,7 +2,7 @@ import type { FaultDashboardResponse } from '../../../shared/assets/faults'; export async function downloadDashboard(data:FaultDashboardResponse,filters:string) { const {default:ExcelJS}=await import('exceljs');const book=new ExcelJS.Workbook();book.creator='羚牛氢能 BI'; const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[ - {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['其中逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, + {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['运维提报',data.totals.operationsReported,'本期新增故障中的运维上报'],['AI提报',data.totals.customerReported,'本期新增故障中的客户上报'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, ...Object.entries(data.distributions).map(([key,rows])=>({name:({region:'区域分布',level:'故障程度',customer:'客户影响',model:'车型分布'} as Record)[key],headers:['分组','本期故障','已闭环','未闭环','影响车辆','当前在办'],rows:rows.map(r=>[r.label,r.reported,r.closed,r.unclosed,r.vehicles,r.open])})), {name:'车型与部位',headers:['品牌车型','故障部位','故障数量'],rows:data.matrix.cells.filter(c=>c.count).map(c=>[c.model,c.category,c.count])}, ]; diff --git a/src/modules/assets/faults/dashboard.css b/src/modules/assets/faults/dashboard.css index 5a7ef91..e8adff7 100644 --- a/src/modules/assets/faults/dashboard.css +++ b/src/modules/assets/faults/dashboard.css @@ -107,19 +107,27 @@ .fault-overview-page .fault-trend-heading h2 { font-size:13px; } } .app-shell-content:has(>.fault-overview-page)>.fixed.top-0 { display:none; } -.fd-kpis-five { grid-template-columns:repeat(5,minmax(0,1fr)); } -.fd-kpis-five>.fd-kpi { display:flex; flex-direction:column; } -.fd-kpis-five .fd-kpi>button { flex-shrink:0; } -.fd-kpis-five .fd-kpi>p, -.fd-kpis-five .fd-kpi-parts { margin-top:auto; min-height:24px; } -.fd-kpis-five .fd-kpi>p { align-items:center; } -.fd-kpis-five .fd-kpi-parts { gap:5px; } -.fd-kpis-five .fd-kpi-parts button { +.fd-kpis>.fd-kpi { display:flex; flex-direction:column; } +.fd-kpis .fd-kpi>button { flex-shrink:0; } +.fd-kpis .fd-kpi>p, +.fd-kpis .fd-kpi-parts { margin-top:auto; min-height:24px; } +.fd-kpis .fd-kpi>p { align-items:center; } +.fd-kpis .fd-kpi-parts { gap:5px; } +.fd-kpis .fd-kpi-parts button { height:24px; min-width:0; padding:3px 7px; line-height:18px; white-space:nowrap; } -.fd-kpis-five .fd-kpi-parts b { line-height:18px; } -.fd-kpis-five .fd-kpi-parts>span { line-height:24px; font-size:12px; } +.fd-kpis .fd-kpi-parts b { line-height:18px; } +.fd-kpis .fd-kpi-parts>span { line-height:24px; font-size:12px; } @media(max-width:1100px) { - .fd-kpis-five { grid-template-columns:repeat(2,minmax(0,1fr)); } - .fd-kpis-five>.fd-kpi:last-child { grid-column:1/-1; } + .fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); } +} + +/* Keep the report-source breakdown and comparison readable at both card widths. */ +.fd-kpis>.fd-kpi { height:148px; } +.fd-kpis .fd-report-parts { margin-top:auto; } +.fd-kpis .fd-reported-kpi>p { min-height:16px; margin-top:6px; } +@media(max-width:767px) { + .fd-kpis>.fd-kpi { height:142px; } + .fd-kpis .fd-report-parts { gap:3px; } + .fd-kpis .fd-report-parts button { padding:3px 4px; } } diff --git a/src/server/routes/asset-faults/dashboard.test.ts b/src/server/routes/asset-faults/dashboard.test.ts index f859404..8d236eb 100644 --- a/src/server/routes/asset-faults/dashboard.test.ts +++ b/src/server/routes/asset-faults/dashboard.test.ts @@ -40,3 +40,97 @@ test('dashboard and report endpoints enforce authentication, permissions, dates const trend=await(await app.request('/f/trend?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(trend.totals.reported,0); const weekly=await(await app.request('/f/weekly-report?start=2026-10-12&end=2026-10-13&level=未标注')).json();assert.equal(weekly.totals.reported,0); }); + +test('focus contains only L1 unclosed faults across all dates and keeps totals and full drill consistent', () => { + const focusRows = [ + fault('waiting', { reported_at: '2026-09-01 08:00:00' }), + fault('processing', { handling_status: '处理中', deadline_at: '2026-10-12 15:00:00' }), + fault('paused', { handling_status: '挂起', deadline_at: '2026-10-01 08:00:00' }), + fault('review', { handling_status: '审核中' }), + fault('unknown', { handling_status: '其他状态' }), + fault('sixth'), + fault('seventh'), + fault('closed', { handling_status: '已归档' }), + fault('l2-overdue', { fault_level: 2, deadline_at: '2026-10-01 08:00:00' }), + fault('l3', { fault_level: 3 }), + fault('l4', { fault_level: 4 }), + fault('unmarked', { fault_level: null }), + ]; + const dashboard = faultDashboard(focusRows, q, updatedAt); + assert.deepEqual(dashboard.focusTotals, { total: 7, overdue: 1 }); + assert.equal(dashboard.focus.length, 5); + assert.equal(dashboard.focus[0].id, 'processing'); + assert.ok(dashboard.focus.every(row => row.level === 'L1-特急' && row.status !== '已归档')); + const allFocus = dashboardAnalysis(focusRows, { ...q, scope: 'focus' }, updatedAt).rows; + assert.equal(allFocus.length, dashboard.focusTotals.total); + assert.ok(allFocus.some(row => row.id === 'waiting')); + assert.ok(allFocus.some(row => row.id === 'paused')); + assert.ok(allFocus.some(row => row.id === 'review')); + assert.ok(allFocus.every(row => !('vehicleKey' in row) && !('managerId' in row))); + assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', id: 'closed' }, updatedAt).rows, []); + const filtered = faultDashboard(focusRows, { ...q, level: 'L2-紧急' }, updatedAt); + assert.deepEqual(filtered.focus, []); + assert.deepEqual(filtered.focusTotals, { total: 0, overdue: 0 }); + assert.deepEqual(dashboardAnalysis(focusRows, { ...q, scope: 'focus', level: 'L2-紧急' }, updatedAt).rows, []); +}); +test('focus dashboard and full drill enforce permissions and business filters before selecting L1 faults', async () => { + const scopedRows = [fault('allowed'), fault('outside', { business_dept: '部门B' }), fault('closed', { handling_status: '已归档' }), fault('lower', { fault_level: 2 })]; + const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); + app.route('/f', createFaultRouter(async () => ({ rows: scopedRows, updatedAt }))); + const report = await (await app.request('/f/dashboard?start=2026-10-12&end=2026-10-13')).json(); + assert.deepEqual(report.focus.map((row: { id: string }) => row.id), ['allowed']); + assert.deepEqual(report.focusTotals, { total: 1, overdue: 0 }); + const response = await app.request('/f/dashboard-analysis?start=2026-10-12&end=2026-10-13&scope=focus'); + assert.equal(response.status, 200); + const detail = await response.json(); + assert.deepEqual(detail.rows.map((row: { id: string }) => row.id), ['allowed']); + const empty = await (await app.request('/f/dashboard-analysis?scope=focus&department=不存在')).json(); + assert.deepEqual(empty.rows, []); +}); + +test('report source follows explicit OneOS report method before legacy task linkage', () => { + const cases: [Record, 'operations' | 'customer'][] = [ + [{ report_method: '运维上报', source_work_order_id: '9007199254740993' }, 'operations'], + [{ report_method: '客户上报', source_work_order_id: null }, 'customer'], + [{ report_method: ' 客户上报 ', source_work_order_id: '123' }, 'customer'], + [{ report_method: null, source_work_order_id: '9007199254740993' }, 'customer'], + [{ report_method: '', source_work_order_id: null }, 'operations'], + [{ report_method: null, source_work_order_id: null, fault_source: 6 }, 'operations'], + ]; + for (const [raw, expected] of cases) assert.equal(fault('source', raw).reportMethod, expected); +}); +test('reported source counts reconcile with cohort, filtered drills, permissions and export labels', async () => { + const sourceRows = [ + fault('ops-linked', { report_method: '运维上报', source_work_order_id: '123' }), + fault('ai-explicit', { report_method: '客户上报', source_work_order_id: null }), + fault('ai-legacy', { report_method: null, source_work_order_id: '9007199254740993', handling_status: '已归档' }), + fault('ops-legacy', { report_method: null, source_work_order_id: null }), + fault('previous-ai', { report_method: '客户上报', reported_at: '2026-10-10 10:00:00' }), + fault('hidden-ai', { report_method: '客户上报', business_dept: '部门B' }), + ]; + const user: AuthUser = { userId: 'test', loginName: 'test', userName: '测试', roles: [], permissionLevel: 'department', depName: '部门A', depCode: 'a' }; + const app = new Hono<{ Variables: { user: AuthUser } }>(); + app.use('*', async (c, next) => { c.set('user', user); await next(); }); + app.route('/f', createFaultRouter(async () => ({ rows: sourceRows, updatedAt }))); + const query = new URLSearchParams(q); + const report = await (await app.request(`/f/dashboard?${query}`)).json(); + assert.equal(report.totals.reported, 4); + assert.equal(report.totals.operationsReported, 2); + assert.equal(report.totals.customerReported, 2); + assert.equal(report.totals.operationsReported + report.totals.customerReported, report.totals.reported); + assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'customer').label, 'AI提报'); + assert.equal(report.options.reportMethod.find((o: { value: string }) => o.value === 'operations').label, '运维提报'); + for (const [method, expected] of [['operations', ['ops-linked', 'ops-legacy']], ['customer', ['ai-explicit', 'ai-legacy']]] as const) { + const response = await app.request(`/f/dashboard-analysis?${query}&scope=cohort&reportMethod=${method}`); + assert.equal(response.status, 200); + const detail = await response.json(); + assert.deepEqual(detail.rows.map((row: { id: string }) => row.id).sort(), [...expected].sort()); + const filtered = await (await app.request(`/f/dashboard?${query}&reportMethod=${method}`)).json(); + assert.equal(filtered.totals.reported, expected.length); + assert.equal(filtered.totals.operationsReported + filtered.totals.customerReported, filtered.totals.reported); + } + const empty = await (await app.request(`/f/dashboard?${query}®ion=不存在`)).json(); + assert.equal(empty.totals.operationsReported, 0); assert.equal(empty.totals.customerReported, 0); +}); diff --git a/src/server/routes/asset-faults/dashboard.ts b/src/server/routes/asset-faults/dashboard.ts index 3e817b5..1dfa7b7 100644 --- a/src/server/routes/asset-faults/dashboard.ts +++ b/src/server/routes/asset-faults/dashboard.ts @@ -1,4 +1,4 @@ -import { FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js'; +import { FAULT_REPORT_METHODS, FAULT_DASHBOARD_FILTERS, type FaultDashboardDimension, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults.js'; import { faultAnalysis, faultOverview, isOverdueFault, type ScopedFault } from './model.js'; import { parseFaultTrendQuery, selectFaultCohort } from './trend.js'; @@ -7,9 +7,11 @@ export function dashboardValues(row: ScopedFault, key: FaultDashboardDimension): if (key === 'level') return [row.level || '未标注']; if (key === 'category') return row.categories?.length ? row.categories : ['未标注']; if (key === 'inventory') return [row.inventory]; + if (key === 'reportMethod') return [row.reportMethod || 'operations']; return [row.assetCell[key]]; } function label(row: ScopedFault, key: FaultDashboardDimension, value: string) { + if (key === 'reportMethod') return FAULT_REPORT_METHODS[value as keyof typeof FAULT_REPORT_METHODS] || value; return key === 'customer' ? row.customerLabel : key === 'handler' ? row.handlers.find(h => h.value === value)?.label || value : value; } export function filterDashboardRows(rows: ScopedFault[], params: Record) { @@ -18,15 +20,19 @@ export function filterDashboardRows(rows: ScopedFault[], params: Record) { const query = parseFaultTrendQuery({ start: params.start, end: params.end }); const scope = params.scope || 'cohort'; - if (!['cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围'); + if (!['focus', 'cohort', 'open', 'waiting', 'processing', 'overdue', 'ledger', 'reported', 'closed'].includes(scope)) throw new Error('无效统计范围'); return { ...query, scope: scope as FaultDashboardScope }; } +function selectFocusFaults(rows: ScopedFault[]) { + return rows.filter(row => row.level === 'L1-特急' && row.status !== '已归档'); +} export function dashboardAnalysis(rows: ScopedFault[], params: Record, updatedAt: string) { const query = parseDashboardQuery(params); let selected = filterDashboardRows(rows, params); if (params.id) selected = selected.filter(row => row.id === params.id); if (query.scope === 'cohort') selected = selectFaultCohort(selected, query); - return faultAnalysis(selected, query.scope === 'cohort' ? 'ledger' : query.scope, updatedAt); + if (query.scope === 'focus') selected = selectFocusFaults(selected); + return faultAnalysis(selected, query.scope === 'cohort' || query.scope === 'focus' ? 'ledger' : query.scope, updatedAt); } export function faultDashboard(visible: ScopedFault[], params: Record, updatedAt: string): FaultDashboardResponse { const query = parseDashboardQuery(params); @@ -56,9 +62,11 @@ export function faultDashboard(visible: ScopedFault[], params: Record r.reportedAt && r.reportedAt.slice(0,10) >= previousStart && r.reportedAt.slice(0,10) < query.start).length; - const focus = faultAnalysis([...open].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows; - return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus, - totals: { reported:cohort.length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size, + const focusRows = selectFocusFaults(all); + const focusTotals = { total: focusRows.length, overdue: focusRows.filter(row => isOverdueFault(row, updatedAt)).length }; + const focus = faultAnalysis([...focusRows].sort((a,b)=> Number(isOverdueFault(b,updatedAt))-Number(isOverdueFault(a,updatedAt)) || (b.reportedAt||'').localeCompare(a.reportedAt||'')).slice(0,5), 'ledger', updatedAt).rows; + return { start:query.start,end:query.end,updatedAt,options,distributions,matrix,focus,focusTotals, + totals: { reported:cohort.length,operationsReported:cohort.filter(r=>r.reportMethod!=='customer').length,customerReported:cohort.filter(r=>r.reportMethod==='customer').length,vehicles:vehicles(cohort),customers:new Set(cohort.filter(r=>!['unlinked','inventory-unlinked'].includes(r.customer)).map(r=>r.customer)).size, previous,change:previous ? (cohort.length-previous)/previous*100 : null,open:overview.open,waiting:overview.waiting,processing:overview.processing,overdue:overview.overdue, longestOverdueDays:Math.max(0,...overdue.map(r=>Math.ceil((Date.parse(updatedAt)-Date.parse(`${r.deadlineAt!.replace(' ','T')}+08:00`))/86400000))), todayReported:overview.reported,todayClosed:overview.closed,ledger:all.length }, diff --git a/src/server/routes/asset-faults/model.ts b/src/server/routes/asset-faults/model.ts index b9bc15a..01a561c 100644 --- a/src/server/routes/asset-faults/model.ts +++ b/src/server/routes/asset-faults/model.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse } from '../../../shared/assets/faults.js'; +import { FAULT_DIMENSIONS, FAULT_OVERVIEW_METRICS, type FaultDimension, type FaultOption, type FaultRecord, type FaultQuery, type FaultResponse, type FaultReportMethod } from '../../../shared/assets/faults.js'; import type { AuthUser } from '../../auth/types.js'; import { filterByPermission, maskCustomerName } from '../../auth/permissions.js'; import type { AssetAnalysisCell } from '../../../shared/assets/analytics.js'; @@ -28,6 +28,13 @@ function array(value: unknown, preserveIds = false): unknown[] { } const text = (value: unknown) => String(value ?? '').trim(); const positiveId = (value: unknown) => /^[1-9]\d*$/.test(text(value)); +// Match OneOS reportMethod: explicit source wins, task linkage is a legacy fallback. +export function faultReportMethod(row: RawFault): FaultReportMethod { + const method = text(row.report_method); + if (method === '运维上报') return 'operations'; + if (method === '客户上报') return 'customer'; + return positiveId(row.source_work_order_id) ? 'customer' : 'operations'; +} export function faultHandlers(row: RawFault, directory: Map): FaultOption[] { const candidates = [ [array(row.work_ids, true), array(row.work_names)], @@ -81,7 +88,7 @@ export function mapFault(row: RawFault, directory: Map): ScopedF vehicleKey: text(row.matched_vehicle_id) || (text(row.plate_number) ? `plate:${text(row.plate_number)}` : ''), level: text(row.level_label) || ({ '1': 'L1-特急', '2': 'L2-紧急', '3': 'L3-一般', '4': 'L4-提示' } as Record)[text(row.fault_level)] || '未标注', categories: [...new Set(text(row.fault_categories).split(/[,,、;;]/).map(v => v.trim()).filter(Boolean))], - progress: text(row.solve_process), + progress: text(row.solve_process), reportMethod: faultReportMethod(row), id: text(row.id), code: text(row.fault_code), plate: text(row.plate_number) || '未关联车辆', description: text(row.fault_desc), reportedAt: text(row.reported_at) || null, archivedAt: text(row.archived_at) || null, deadlineAt: text(row.deadline_at) || null, receivedAt: text(row.received_at) || null, city, status: normalizeFaultStatus(row.handling_status), assetCell, diff --git a/src/server/routes/asset-faults/repository.ts b/src/server/routes/asset-faults/repository.ts index 15477f1..e1dc143 100644 --- a/src/server/routes/asset-faults/repository.ts +++ b/src/server/routes/asset-faults/repository.ts @@ -4,7 +4,8 @@ import { mapFault, type RawFault } from './model.js'; // OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause. export const FAULT_SQL = `SELECT - f.fault_level, f.fault_categories, f.solve_process, + f.fault_level, f.fault_categories, f.solve_process, f.report_method, + CAST(f.source_work_order_id AS CHAR) source_work_order_id, CAST(f.id AS CHAR) id, f.fault_code, f.plate_number, f.fault_desc, f.handling_status, DATE_FORMAT(f.fault_report_time, '%Y-%m-%d %H:%i:%s') reported_at, DATE_FORMAT(f.archived_at, '%Y-%m-%d %H:%i:%s') archived_at, diff --git a/src/shared/assets/faults.ts b/src/shared/assets/faults.ts index 1b56219..0f34d6c 100644 --- a/src/shared/assets/faults.ts +++ b/src/shared/assets/faults.ts @@ -8,13 +8,15 @@ export type FaultDimension = keyof typeof FAULT_DIMENSIONS; export type FaultMetric = 'reported' | 'closed' | 'open'; export interface FaultOption { value: string; label: string } export interface FaultCounts { reported: number; closed: number; open: number } +export const FAULT_REPORT_METHODS = { operations: '运维提报', customer: 'AI提报' } as const; +export type FaultReportMethod = keyof typeof FAULT_REPORT_METHODS; export interface FaultRecord { id: string; code: string; plate: string; description: string; reportedAt: string | null; archivedAt: string | null; deadlineAt: string | null; receivedAt: string | null; city: string; status: string; inventory: string; customer: string; customerLabel: string; department: string; handlers: FaultOption[]; - level?: string; categories?: string[]; progress?: string; + level?: string; categories?: string[]; progress?: string; reportMethod?: FaultReportMethod; } export interface FaultQuery { start: string; end: string; groupBy: FaultDimension; @@ -65,17 +67,18 @@ export interface FaultWeeklyResponse { days: (Record & { date: string })[]; } -export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态' } as const; +export const FAULT_DASHBOARD_FILTERS = { region: '区域', customer: '客户', model: '品牌车型', city: '城市', department: '业务部门', handler: '运维处置人', level: '故障程度', category: '故障部位', inventory: '库存状态', reportMethod: '提报来源' } as const; export type FaultDashboardDimension = keyof typeof FAULT_DASHBOARD_FILTERS; export type FaultDashboardFilters = Partial>; -export type FaultDashboardScope = 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed'; +export type FaultDashboardScope = 'focus' | 'cohort' | 'open' | 'waiting' | 'processing' | 'overdue' | 'ledger' | 'reported' | 'closed'; export interface FaultDistribution { value: string; label: string; reported: number; closed: number; unclosed: number; vehicles: number; open: number } export interface FaultDashboardResponse { start: string; end: string; updatedAt: string; - totals: { reported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number }; + totals: { reported: number; operationsReported: number; customerReported: number; vehicles: number; customers: number; previous: number; change: number | null; open: number; waiting: number; processing: number; overdue: number; longestOverdueDays: number; todayReported: number; todayClosed: number; ledger: number }; options: Record; distributions: Record<'region' | 'level' | 'customer' | 'model', FaultDistribution[]>; matrix: { models: FaultOption[]; categories: FaultOption[]; cells: { model: string; category: string; count: number }[]; totals: Record }; focus: FaultAnalysisRecord[]; + focusTotals: { total: number; overdue: number }; quality: { missingLevel: number; missingCategory: number; missingVehicle: number; missingDeadline: number; missingReport: number }; } From dbe0421bfedf445e73e8213a0a801df1e6ae9ae1 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Fri, 16 Oct 2026 18:23:57 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(assets):=20=E7=A7=BB=E9=99=A4=E6=95=85?= =?UTF-8?q?=E9=9A=9C=E6=8F=90=E6=8A=A5=E6=9D=A5=E6=BA=90=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/asset-fault-management.md | 2 +- src/modules/assets/faults/FaultManagement.tsx | 2 +- src/modules/assets/faults/dashboard-export.ts | 2 +- src/modules/assets/faults/dashboard.css | 10 ---------- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/asset-fault-management.md b/docs/asset-fault-management.md index 8d9d7e0..85e6780 100644 --- a/docs/asset-fault-management.md +++ b/docs/asset-fault-management.md @@ -84,7 +84,7 @@ 故障管理按最新确认保留「管理总览 / 多维分析 / 故障明细」三个页签。管理总览与处理效率合并,恢复原有页面及统计:今日新增、本日解决、当前在办(待处理 + 处理中,逾期为子集)、累计台账,沿用 `/overview` 接口;下方直接展示原有故障处理趋势与故障运维周报。趋势默认近30天,周报默认周六至周五,分别选择日期、导出和下钻。管理总览不受其他页签的业务筛选影响。多维分析、故障明细保留新增分析能力及统一日期、区域、客户、品牌车型查询;更多筛选包含城市、业务部门、运维处置人、故障程度、故障部位、库存状态,点击查询生效。 -- 期间新增故障:所选提报日期内的有效故障。卡片增加“运维提报”和“AI提报”数量及下钻;两者之和等于期间新增,沿用日期、权限及业务筛选。来源优先读取 `report_method`:运维上报显示“运维提报”,客户上报显示“AI提报”;旧数据未标明有效上报方式时,有真实 `source_work_order_id` 关联归客户上报,否则归运维上报,与 OneOS 历史展示逻辑一致。该分类不使用 `fault_source` 故障来源字典,也不推断所有客户上报均由 AI 自动创建。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 +- 期间新增故障:所选提报日期内的有效故障。卡片及导出仅展示期间新增总量,不再拆分提报来源。对比上期采用紧邻本期、天数相同的区间;上期为零时显示无可比基数。 - 受影响车辆:同批故障按关联车辆 ID 去重,缺 ID 时使用故障车牌;两者都缺失的不计车辆数,单列数据完整性提示。车辆跨组可能重复,不直接累加。 - 当前在办:已应用业务筛选下,所有日期的待处理 + 处理中。逾期为其子集;最长逾期按截止时间到数据时间的天数向上取整。 - 区域、等级、客户、车型分布和车型×部位矩阵:按本期提报队列统计,闭环状态截至更新时间。一单多部位分别计入单元格,车型合计按单去重。 diff --git a/src/modules/assets/faults/FaultManagement.tsx b/src/modules/assets/faults/FaultManagement.tsx index 528a362..d6dd581 100644 --- a/src/modules/assets/faults/FaultManagement.tsx +++ b/src/modules/assets/faults/FaultManagement.tsx @@ -43,7 +43,7 @@ export default function FaultManagement(){ {!data&&loading?
正在汇总故障分析…
:data&&
{tab==='多维分析'&&<>
-

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

+
+

对比上期 0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}上期 {t?.previous} 单

涉及 {t?.customers} 家已关联客户按车辆去重

+

最长逾期 {t?.longestOverdueDays} 天在办子集

diff --git a/src/modules/assets/faults/dashboard-export.ts b/src/modules/assets/faults/dashboard-export.ts index 3931747..2cfb3e0 100644 --- a/src/modules/assets/faults/dashboard-export.ts +++ b/src/modules/assets/faults/dashboard-export.ts @@ -2,7 +2,7 @@ import type { FaultDashboardResponse } from '../../../shared/assets/faults'; export async function downloadDashboard(data:FaultDashboardResponse,filters:string) { const {default:ExcelJS}=await import('exceljs');const book=new ExcelJS.Workbook();book.creator='羚牛氢能 BI'; const tables: {name:string;headers:string[];rows:(string|number)[][]}[]=[ - {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['运维提报',data.totals.operationsReported,'本期新增故障中的运维上报'],['AI提报',data.totals.customerReported,'本期新增故障中的客户上报'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, + {name:'管理总览',headers:['指标','数量','口径'],rows:[['期间新增故障',data.totals.reported,'按提报日期'],['受影响车辆',data.totals.vehicles,'本期故障车辆去重'],['当前在办',data.totals.open,'全部待处理 + 处理中'],['逾期',data.totals.overdue,'当前在办中超过处置截止时间'],['待处理',data.totals.waiting,'全部待处理'],['处理中',data.totals.processing,'全部处理中'],['今日新增',data.totals.todayReported,'北京时间今日提报'],['本日解决',data.totals.todayClosed,'北京时间今日解决'],['累计台账',data.totals.ledger,'当前筛选范围的全部有效故障']]}, ...Object.entries(data.distributions).map(([key,rows])=>({name:({region:'区域分布',level:'故障程度',customer:'客户影响',model:'车型分布'} as Record)[key],headers:['分组','本期故障','已闭环','未闭环','影响车辆','当前在办'],rows:rows.map(r=>[r.label,r.reported,r.closed,r.unclosed,r.vehicles,r.open])})), {name:'车型与部位',headers:['品牌车型','故障部位','故障数量'],rows:data.matrix.cells.filter(c=>c.count).map(c=>[c.model,c.category,c.count])}, ]; diff --git a/src/modules/assets/faults/dashboard.css b/src/modules/assets/faults/dashboard.css index e8adff7..40ef0ca 100644 --- a/src/modules/assets/faults/dashboard.css +++ b/src/modules/assets/faults/dashboard.css @@ -121,13 +121,3 @@ @media(max-width:1100px) { .fd-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); } } - -/* Keep the report-source breakdown and comparison readable at both card widths. */ -.fd-kpis>.fd-kpi { height:148px; } -.fd-kpis .fd-report-parts { margin-top:auto; } -.fd-kpis .fd-reported-kpi>p { min-height:16px; margin-top:6px; } -@media(max-width:767px) { - .fd-kpis>.fd-kpi { height:142px; } - .fd-kpis .fd-report-parts { gap:3px; } - .fd-kpis .fd-report-parts button { padding:3px 4px; } -}