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 }; +}