This commit is contained in:
@@ -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:后续如提供统一品牌资产,可单独统一应用级导航标识;当前保持现有产品导航。
|
||||||
@@ -79,3 +79,23 @@
|
|||||||
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
|
- 周报:日期、星期、新增故障、解决故障,按日期倒序;不导出接收处理指标。
|
||||||
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
|
- XLSX 包含带标题、范围、数据时间、汇总指标的统计表;当前有图表时附加「图表」工作表,以图片保存页面图表快照并保持比例。图表为静态快照,修改统计表不会联动图表。
|
||||||
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 Excel 日期和数字格式、合计及闭环率公式、冻结表头、筛选与打印设置。ExcelJS 按导出操作动态加载。
|
- 统一深色表头、指标配色、交替底色、今日浅色标记;真实 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 工作表:管理总览、区域分布、故障程度、客户影响、车型分布、车型与部位。导出已加载统计快照,包含日期、业务筛选、数据时间、冻结表头、交替底色和数字格式。趋势、周报的既有导出保留,并增加业务筛选说明。
|
||||||
|
|||||||
@@ -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<string, string>) => 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 <>
|
||||||
|
<div className="fd-grid fd-grid-primary">
|
||||||
|
<section className="fd-panel" aria-label="故障分布与对比"><div className="fd-panel-title"><h2>故障分布与对比</h2><span>按本期提报队列统计,截至当前的闭环状态。</span></div>
|
||||||
|
<div className="fd-chart-controls"><div className="fd-segment" aria-label="故障分布维度">{([['region','区域'],['level','程度'],['customer','客户'],['model','车型']] as const).map(([key,label])=><button key={key} aria-pressed={dimension===key} onClick={()=>{setDimension(key);setExpanded(false);}}>{label}</button>)}</div><div className="fd-segment" aria-label="故障分布指标"><button aria-pressed={metric==='reported'} onClick={()=>setMetric('reported')}>故障量</button><button aria-pressed={metric==='vehicles'} onClick={()=>setMetric('vehicles')}>受影响车辆</button><button disabled title="缺少同期服务车辆规模,暂不计算发生频率">发生频率 · 待补分母</button></div></div>
|
||||||
|
<div className="fd-legend">{metric==='reported'?<><span className="fd-blue">已闭环 {groups.reduce((n,r)=>n+r.closed,0)}</span><span className="fd-orange">未闭环 {groups.reduce((n,r)=>n+r.unclosed,0)}</span></>:<span className="fd-blue">按车辆去重 · 辆</span>}</div>
|
||||||
|
{shown.length?<div className="fd-distribution-chart"><ResponsiveContainer width="100%" height={Math.max(150,shown.length*26)} minWidth={0}><BarChart data={shown} layout="vertical" margin={{top:4,right:30,left:0,bottom:0}}>
|
||||||
|
<CartesianGrid horizontal={false} stroke="#edf2fa"/><XAxis type="number" allowDecimals={false} axisLine={false} tickLine={false} tick={{fontSize:10,fill:'#7c8ba7'}}/><YAxis type="category" dataKey="label" interval={0} width={116} axisLine={false} tickLine={false} tick={{fontSize:11,fill:'#334155'}} tickFormatter={v=>v.length>12?`${v.slice(0,11)}…`:v}/><Tooltip cursor={{fill:'#f4f7fc'}} contentStyle={{borderRadius:8,borderColor:'#e2e8f0',fontSize:12}}/>
|
||||||
|
{metric==='reported'?<><Bar dataKey="closed" name="已闭环" stackId="faults" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey={(row:FaultDistribution)=>row.unclosed===0?row.reported:''} position="right" fontSize={10} fill="#334155"/></Bar><Bar dataKey="unclosed" name="未闭环" stackId="faults" fill="#ff981a" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'故障分布')}><LabelList dataKey="reported" position="right" fontSize={10} fill="#334155"/></Bar></>:<Bar dataKey="vehicles" name="受影响车辆" fill="#2475ff" maxBarSize={18} isAnimationActive={false} cursor="pointer" onClick={d=>chartDrill(d,dimension,'受影响车辆对应故障')}><LabelList dataKey="vehicles" position="right" fontSize={10} fill="#334155"/></Bar>}</BarChart></ResponsiveContainer></div>:<p className="fd-empty">所选条件下暂无故障。</p>}
|
||||||
|
<div className="fd-panel-foot"><span>点击图表查看对应故障;车辆跨组可能重复,不直接累加。</span>{groups.length>8&&<button onClick={()=>setExpanded(!expanded)}>{expanded?'收起':`全部 ${groups.length} 组`}</button>}</div>
|
||||||
|
<details className="fd-chart-data"><summary>查看精确数量与下钻</summary><table><thead><tr><th>分组</th><th>故障量</th><th>已闭环</th><th>未闭环</th><th>车辆</th></tr></thead><tbody>{groups.map(r=><tr key={r.value}><td><button onClick={()=>drill(r.label,'cohort',{[dimension]:r.value})}>{r.label}</button></td><td>{r.reported}</td><td>{r.closed}</td><td>{r.unclosed}</td><td>{r.vehicles}</td></tr>)}</tbody></table></details>
|
||||||
|
</section>
|
||||||
|
<section className="fd-panel" aria-label="故障程度"><div className="fd-panel-title"><h2>故障程度</h2><span className="fd-chip">现有等级口径</span></div><p className="fd-caption">按本期故障数量统计,点击等级查看明细。</p>
|
||||||
|
<div className="fd-level-list">{levels.map(row=><button key={row.value} onClick={()=>drill(`故障程度 · ${row.label}`,'cohort',{level:row.value})}><span>{row.label}</span><span className="fd-level-track"><span style={{width:`${row.reported/Math.max(1,...levels.map(r=>r.reported))*100}%`,background:colors[/^L[1-4]/.test(row.value)?Number(row.value[1])-1:4]}}/></span><b>{row.reported}</b><ArrowUpRight size={12}/></button>)}</div>
|
||||||
|
{!levels.length&&<p className="fd-empty">暂无等级统计。</p>}
|
||||||
|
<p className="fd-info"><Info size={15}/>等级不等于实际停运影响,请结合车辆状态与服务记录判断。</p><p className="fd-caption">未标注等级 {data.quality.missingLevel} 单;停运车辆与修复时长待补数据。</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="fd-grid fd-grid-secondary">
|
||||||
|
<section className="fd-panel" aria-label="品牌车型与故障部位"><div className="fd-panel-title"><h2>品牌车型 × 故障部位</h2><span>点击交叉单元格查看对应故障。</span></div><div className="fd-table-scroll"><table className="fd-matrix"><thead><tr><th>品牌车型</th>{data.matrix.categories.map(c=><th key={c.value}>{c.label}</th>)}<th>合计</th></tr></thead><tbody>{models.map(m=><tr key={m.value}><th title={m.label}>{m.label}</th>{data.matrix.categories.map(c=>{const count=data.matrix.cells.find(cell=>cell.model===m.value&&cell.category===c.value)?.count||0;return <td key={c.value} style={{background:count?`rgba(36,117,255,${.08+.52*count/max})`:undefined}}><button disabled={!count} aria-label={`${m.label},${c.label},${count} 单`} onClick={()=>drill(`${m.label} · ${c.label}`,'cohort',{model:m.value,category:c.value})}>{count||'—'}</button></td>})}<td><button onClick={()=>drill(m.label,'cohort',{model:m.value})}>{data.matrix.totals[m.value]}</button></td></tr>)}</tbody></table></div>{!models.length&&<p className="fd-empty">暂无车型与部位数据。</p>}<div className="fd-panel-foot"><span>一单多部位分别计入,合计按故障单去重。</span>{data.matrix.models.length>3&&<button onClick={()=>setMatrixExpanded(!matrixExpanded)}>{matrixExpanded?'收起':`全部 ${data.matrix.models.length} 个车型`}</button>}</div></section>
|
||||||
|
<section className="fd-panel" aria-label="客户受影响情况"><div className="fd-panel-title"><h2>客户受影响情况</h2><span>同一车辆在同一客户下去重。</span></div><div className="fd-table-scroll"><table className="fd-customers"><thead><tr><th>#</th><th>客户名称</th><th>本期影响车辆</th><th>当前在办</th></tr></thead><tbody>{(customersExpanded?customers:customers.slice(0,4)).map((r,i)=><tr key={r.value}><td>{i+1}</td><td><button onClick={()=>drill(r.label,'cohort',{customer:r.value})}>{r.label}</button></td><td><button className="fd-customer-meter" onClick={()=>drill(`${r.label} · 本期故障`,'cohort',{customer:r.value})}><progress max={Math.max(1,customers[0]?.vehicles||0)} value={r.vehicles}/><b>{r.vehicles}</b></button></td><td><button className="fd-danger" disabled={!r.open} onClick={()=>drill(`${r.label} · 当前在办`,'open',{customer:r.value})}>{r.open}</button></td></tr>)}</tbody></table></div>{!customers.length&&<p className="fd-empty">暂无客户影响数据。</p>}<div className="fd-panel-foot"><span>当前在办不限制提报日期;库存车单独展示。</span>{customers.length>4&&<button onClick={()=>setCustomersExpanded(!customersExpanded)}>{customersExpanded?'收起':`全部 ${customers.length} 组`}</button>}</div></section>
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
export function DashboardTicketTable({rows,updatedAt,drill}:{rows:FaultAnalysisRecord[];updatedAt:string;drill:DashboardDrill}) {
|
||||||
|
return <div className="fd-table-scroll"><table className="fd-tickets"><thead><tr><th>车辆 / 故障单号</th><th>运维处置人</th><th>区域 / 客户</th><th>等级</th><th>当前状态</th><th>处置记录 / 故障描述</th><th>提报时间</th><th>操作</th></tr></thead><tbody>{rows.map(row=>{const overdue=['待处理','处理中'].includes(row.status)&&row.deadlineAt&&Date.parse(`${row.deadlineAt.replace(' ','T')}+08:00`)<Date.parse(updatedAt);return <tr key={row.id}><td><strong>{row.plate}</strong><small>{row.code}</small></td><td>{row.handlers.map(h=>h.label).join('、')}</td><td>{row.cell.region}<small>{row.customerLabel}</small></td><td><span className={`fd-grade fd-grade-${row.level?.slice(0,2)}`}>{row.level||'未标注'}</span></td><td><span className={row.status==='已归档'?'fd-green':'fd-orange'}>{row.status==='已归档'?'已闭环':row.status}</span>{overdue&&<small className="fd-overdue-tag">已逾期</small>}</td><td className="fd-description" title={row.progress||row.description}>{row.progress||row.description||'暂无处置记录'}</td><td>{row.reportedAt||'未记录'}</td><td><button onClick={()=>drill(`${row.plate} · ${row.code}`,'ledger',{id:row.id})}>查看</button></td></tr>})}</tbody></table>{!rows.length&&<p className="fd-empty">当前条件下暂无故障。</p>}</div>;
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import '../components/analysis/mobile-analysis.css';
|
|||||||
const available = FAULT_ANALYSIS_DIMENSIONS;
|
const available = FAULT_ANALYSIS_DIMENSIONS;
|
||||||
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
|
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
|
||||||
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
|
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
|
||||||
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 }) {
|
export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useDialogFocus(true, ref, onClose);
|
useDialogFocus(true, ref, onClose);
|
||||||
@@ -38,7 +38,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setError(''); setData(null);
|
setError(''); setData(null);
|
||||||
fetchJson<FaultAnalysisResponse>(source?.url || `/api/asset-faults/analysis?metric=${metric}`, { signal: controller.signal })
|
fetchJson<FaultAnalysisResponse>(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();
|
return () => controller.abort();
|
||||||
}, [metric, revision, source?.url]);
|
}, [metric, revision, source?.url]);
|
||||||
const records = useMemo(() => data?.rows || [], [data]);
|
const records = useMemo(() => data?.rows || [], [data]);
|
||||||
|
|||||||
@@ -1,64 +1,71 @@
|
|||||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react';
|
import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
|
||||||
import { fetchJson } from '../../../auth/api-client';
|
import { fetchJson } from '../../../auth/api-client';
|
||||||
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults';
|
import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults';
|
||||||
import { FaultAnalysisModal } from './FaultAnalysisModal';
|
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||||
import { FaultWeeklyReport } from './FaultWeeklyReport';
|
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||||
import { FaultTrendCard } from './FaultTrendCard';
|
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';
|
import './fault-management.css';
|
||||||
|
import './dashboard.css';
|
||||||
export const FAULT_DEFINITIONS = {
|
const tabs = ['管理总览','多维分析','故障明细'] as const;
|
||||||
reported: '按今日提报时间统计', closed: '按今日解决时间统计',
|
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};}
|
||||||
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账',
|
export default function FaultManagement(){
|
||||||
};
|
const [range,setRange]=useState(initialRange),[filters,setFilters]=useState<FaultDashboardFilters>({});
|
||||||
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList };
|
const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters}));
|
||||||
const metrics = ['reported', 'closed', 'open', 'ledger'] as const;
|
const [tab,setTab]=useState<typeof tabs[number]>('管理总览'),[more,setMore]=useState(false);
|
||||||
export default function FaultManagement() {
|
const [data,setData]=useState<FaultDashboardResponse|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState('');
|
||||||
const [data, setData] = useState<FaultOverview | null>(null);
|
const [revision,setRevision]=useState(0),[source,setSource]=useState<FaultAnalysisSource|null>(null);
|
||||||
const [error, setError] = useState('');
|
const [detailScope,setDetailScope]=useState<FaultDashboardScope>('cohort'),[detailData,setDetailData]=useState<FaultAnalysisResponse|null>(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [search,setSearch]=useState(''),[limit,setLimit]=useState(50);
|
||||||
const [revision, setRevision] = useState(0);
|
const close=useCallback(()=>setSource(null),[]);
|
||||||
const [metric, setMetric] = useState<FaultOverviewMetric | null>(null);
|
const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString();
|
||||||
const close = useCallback(() => setMetric(null), []);
|
useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson<FaultDashboardResponse>(`/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(() => {
|
useEffect(()=>{if(tab!=='故障明细')return;const c=new AbortController();setDetailData(null);setDetailError('');setDetailLoading(true);setLimit(50);fetchJson<FaultAnalysisResponse>(`/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 controller = new AbortController();
|
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'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});};
|
||||||
setLoading(true); setError('');
|
const t=data?.totals;
|
||||||
fetchJson<FaultOverview>('/api/asset-faults/overview', { signal: controller.signal })
|
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}));
|
||||||
.then(setData).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters);
|
||||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—';
|
||||||
return () => controller.abort();
|
const select=(key:FaultDashboardDimension)=><label className="fd-filter" key={key}><span>{FAULT_DASHBOARD_FILTERS[key]}</span><select aria-label={FAULT_DASHBOARD_FILTERS[key]} value={filters[key]||''} onChange={e=>setFilters(v=>({...v,[key]:e.target.value}))}><option value="">全部</option>{data?.options[key].map(o=><option key={o.value} value={o.value}>{o.label}</option>)}</select></label>;
|
||||||
}, [revision]);
|
const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');};
|
||||||
return <div className="fault-page">
|
const navigation=<nav className="fd-tabs" aria-label="故障分析页面">{tabs.map(name=><button key={name} aria-current={tab===name?'page':undefined} onClick={()=>setTab(name)}>{name}</button>)}</nav>;
|
||||||
<header className="fault-page-header"><h1>羚牛氢能-故障管理</h1><div><span>{data ? `数据更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}</span><button aria-label="刷新故障统计" disabled={loading} onClick={() => setRevision(v => v + 1)}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} />刷新</button></div></header>
|
if(tab==='管理总览')return <FaultOverview navigation={navigation}/>;
|
||||||
{error && <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div>}
|
return <div className="fault-page fd-page">
|
||||||
<section className="fault-summary" aria-label="故障总统计" aria-busy={loading}>
|
<header className="fd-header"><div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>故障风险与服务分析</h1><p>从问题分布,追溯到车辆与处理进展。</p></div></div><div className="fd-header-actions"><span>更新时间:{data?new Date(data.updatedAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai',hour12:false}):'读取中'}</span><button disabled={loading} aria-label="刷新故障统计" onClick={()=>setRevision(v=>v+1)}><RefreshCw size={14} className={loading?'animate-spin':''}/>刷新</button><AnalysisExportButton disabled={loading||!data||!!error} onExport={()=>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}/></div></header>
|
||||||
<div className="fault-summary-heading"><h2>故障数据实时汇总</h2><span>{data?.date || '—'} · 北京时间</span></div>
|
{navigation}
|
||||||
<div className="fault-metrics">{metrics.map(key => {
|
<form className="fd-toolbar" onSubmit={e=>{e.preventDefault();setApplied({...range,filters:{...filters}});}}><div className="fd-date"><FlowDateRangePicker value={range} onChange={setRange} dateLabel="故障提报时间"/></div>{(['region','customer','model'] as const).map(select)}<button className="fd-filter-toggle" type="button" aria-expanded={more} onClick={()=>setMore(!more)}><Filter size={15}/>更多筛选{Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length?` (${Object.entries(filters).filter(([k,v])=>!['region','customer','model'].includes(k)&&v).length})`:''}</button><button className="fd-query" type="submit" disabled={loading}>查询</button>{more&&<div className="fd-more-filters">{(['city','department','handler','level','category','inventory'] as const).map(select)}<button type="button" onClick={()=>{setFilters({});setApplied({...range,filters:{}});}}>重置筛选</button></div>}</form>
|
||||||
const Icon = icons[key];
|
<p className="fd-scope-note">期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&<strong>条件已修改,点击查询后生效。</strong>}</p>
|
||||||
return <article key={key} className={`fault-metric fault-metric-${key}`}><button className="fault-metric-main" disabled={!data || loading || !!error} onClick={() => setMetric(key)} aria-label={`${FAULT_OVERVIEW_METRICS[key]} ${data?.totals[key] ?? '加载中'} 单,查看分析`}>
|
{error&&<div className="fault-message" role="alert">{error}<button onClick={()=>setRevision(v=>v+1)}>重试</button></div>}
|
||||||
<div className="fault-metric-label"><span className="fault-metric-icon"><Icon size={19} /></span><span>{FAULT_OVERVIEW_METRICS[key]}</span><ArrowUpRight size={15} /></div>
|
{!data&&loading?<div className="fd-loading" role="status"><RefreshCw className="animate-spin" size={20}/>正在汇总故障分析…</div>:data&&<div className={loading||error?'fd-content is-loading':'fd-content'} aria-busy={loading}>
|
||||||
<div className="fault-metric-count"><strong>{loading ? '—' : data?.totals[key].toLocaleString('zh-CN') ?? '—'}</strong><span>单</span></div>
|
{tab==='多维分析'&&<>
|
||||||
<p>{FAULT_DEFINITIONS[key]}</p><span className="fault-metric-link">查看分析与明细 →</span>
|
<section className="fd-kpis fd-kpis-five" aria-label="故障总统计">
|
||||||
</button>
|
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('期间新增故障')}><ClipboardList className="fd-kpi-icon"/><span>期间新增故障</span><ArrowUpRight size={14}/><strong>{count(t?.reported)}<small>单</small></strong></button><p>对比上期 <b className={t?.change!=null&&t.change>0?'fd-orange':'fd-blue'}>{t?.change==null?'无可比基数':`${t.change>0?'+':''}${t.change.toFixed(1)}%`}</b><small>上期 {t?.previous} 单</small></p></article>
|
||||||
{key === 'open' && <>
|
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('受影响车辆对应故障')}><CarFront className="fd-kpi-icon fd-purple"/><span>受影响车辆</span><ArrowUpRight size={14}/><strong>{count(t?.vehicles)}<small>辆</small></strong></button><p>涉及 <b>{t?.customers}</b> 家已关联客户<small>按车辆去重</small></p></article>
|
||||||
<button className="fault-overdue-subset" disabled={!data || loading || !!error} onClick={() => setMetric('overdue')} aria-label={`其中逾期 ${data?.totals.overdue ?? '加载中'} 单,查看分析`}>
|
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('当前在办','open')}><Clock3 className="fd-kpi-icon fd-orange"/><span>当前在办</span><ArrowUpRight size={14}/><strong>{count(t?.open)}<small>单</small></strong></button><div className="fd-kpi-parts"><button disabled={loading||!!error} onClick={()=>drill('待处理','waiting')}>待处理 <b>{count(t?.waiting)}</b></button><span>+</span><button disabled={loading||!!error} onClick={()=>drill('处理中','processing')}>处理中 <b>{count(t?.processing)}</b></button></div></article>
|
||||||
<span><span className="fault-overdue-prefix">其中</span>逾期</span><strong>{loading ? '—' : data?.totals.overdue?.toLocaleString('zh-CN') ?? '—'}</strong><small>单</small>
|
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('其中逾期','overdue')}><TriangleAlert className="fd-kpi-icon fd-danger"/><span>其中逾期</span><ArrowUpRight size={14}/><strong className="fd-danger">{count(t?.overdue)}<small>单</small></strong></button><p>最长逾期 <b className="fd-danger">{t?.longestOverdueDays} 天</b><small>在办子集</small></p></article>
|
||||||
</button>
|
<article className="fd-kpi"><button disabled={loading||!!error} onClick={()=>drill('累计台账','ledger')}><ClipboardList className="fd-kpi-icon fd-purple"/><span>累计台账</span><ArrowUpRight size={14}/><strong>{count(t?.ledger)}<small>单</small></strong></button><p>全部有效故障台账<small>不限提报日期</small></p></article>
|
||||||
<div className="fault-open-breakdown" aria-label="当前在办等于待处理加处理中">
|
|
||||||
{(['waiting', 'processing'] as const).map((part, index) => <Fragment key={part}>
|
|
||||||
{index > 0 && <span className="fault-composition-plus" aria-hidden="true">+</span>}
|
|
||||||
<button className={`fault-submetric fault-submetric-${part}`} disabled={!data || loading || !!error} onClick={() => setMetric(part)} aria-label={`${FAULT_OVERVIEW_METRICS[part]} ${data?.totals[part] ?? '加载中'} 单,查看分析`}>
|
|
||||||
<span>{FAULT_OVERVIEW_METRICS[part]}</span><strong>{loading ? '—' : data?.totals[part]?.toLocaleString('zh-CN') ?? '—'}</strong>
|
|
||||||
</button>
|
|
||||||
</Fragment>)}
|
|
||||||
</div>
|
|
||||||
</>}
|
|
||||||
</article>;
|
|
||||||
})}</div>
|
|
||||||
<p className="fault-summary-note">当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。</p>
|
|
||||||
</section>
|
</section>
|
||||||
<FaultTrendCard refresh={revision} />
|
<div className="fd-today-stats"><span>实时统计</span><button onClick={()=>drill('今日新增','reported')}>今日新增 <b>{t?.todayReported}</b></button><button onClick={()=>drill('本日解决','closed')}>本日解决 <b>{t?.todayClosed}</b></button></div>
|
||||||
<FaultWeeklyReport refresh={revision} />
|
<div className="fd-insight"><Info size={18}/><strong>{data.distributions.region[0]?`本期关注:${data.distributions.region[0].label}故障量最高,共 ${data.distributions.region[0].reported} 单;当前 ${t?.overdue} 单在办故障已逾期。`:'所选条件下暂无本期故障,可调整日期或筛选范围。'}</strong><span>按数量描述,不代表故障发生率。</span><button disabled={!t?.open} onClick={showAllOpen}>查看关联故障 <ArrowUpRight size={13}/></button></div>
|
||||||
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
|
{tab==='多维分析'&&<div className="fd-analysis-banner"><div><h2>组合维度,追溯故障</h2><p>支持运维处置人、车牌、工单状态、车型、区域、客户等 12 个维度,多选筛选与明细下钻。</p></div><button className="fd-query" onClick={()=>drill('本期故障多维分析')}>打开多维分析</button></div>}
|
||||||
|
<DashboardPanels data={data} drill={drill}/>
|
||||||
|
<section className="fd-panel fd-focus" aria-label="重点故障跟进">
|
||||||
|
<div className="fd-panel-title">
|
||||||
|
<h2>重点故障跟进</h2>
|
||||||
|
<span className="fd-chip fd-orange">当前在办 {t?.open}</span>
|
||||||
|
<span className="fd-chip fd-danger">其中逾期 {t?.overdue}</span>
|
||||||
|
<button onClick={showAllOpen}>查看全部 <ArrowUpRight size={13}/></button>
|
||||||
|
</div>
|
||||||
|
<DashboardTicketTable rows={data.focus} updatedAt={data.updatedAt} drill={drill}/>
|
||||||
|
<p className="fd-caption">沿用当前业务筛选,不限制提报日期;优先展示逾期故障,同优先级按提报时间倒序,最多展示 5 单。完整明细见“查看全部”。</p>
|
||||||
|
</section>
|
||||||
|
</>}
|
||||||
|
{tab==='故障明细'&&<section className="fd-panel fd-details"><div className="fd-panel-title"><h2>故障明细</h2><span>按提报时间倒序 · {detailRows.length} 单</span><button onClick={()=>drill('故障明细多维分析',detailScope)}>多维分析 <ArrowUpRight size={13}/></button></div><div className="fd-detail-controls"><div className="fd-segment">{([['cohort','本期提报'],['open','当前在办'],['overdue','逾期故障'],['ledger','累计台账']] as const).map(([key,title])=><button key={key} aria-pressed={detailScope===key} onClick={()=>{setDetailScope(key);setSearch('');}}>{title}</button>)}</div><label><Search size={15}/><input placeholder="搜索车牌、单号、处置人" aria-label="搜索故障明细列表" value={search} onChange={e=>{setSearch(e.target.value);setLimit(50);}}/></label></div>{detailLoading?<p className="fd-loading" role="status">正在加载故障明细…</p>:detailError?<p role="alert" className="fault-message">{detailError}<button onClick={()=>setRevision(v=>v+1)}>重试</button></p>:<><DashboardTicketTable rows={detailRows.slice(0,limit)} updatedAt={detailData?.updatedAt||data.updatedAt} drill={drill}/>{detailRows.length>limit&&<button className="fd-load-more" onClick={()=>setLimit(v=>v+50)}>加载更多({Math.min(limit,detailRows.length)} / {detailRows.length})</button>}</>}</section>}
|
||||||
|
<footer className="fd-quality"><Info size={13}/>数据完整性:本期未标注等级 {data.quality.missingLevel} 单,未标注部位 {data.quality.missingCategory} 单,缺少车辆标识 {data.quality.missingVehicle} 单;在办中 {data.quality.missingDeadline} 单未设置截止时间,不计入逾期。</footer>
|
||||||
|
</div>}
|
||||||
|
{source&&<FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close}/>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<FaultOverview | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [revision, setRevision] = useState(0);
|
||||||
|
const [metric, setMetric] = useState<FaultOverviewMetric | null>(null);
|
||||||
|
const close = useCallback(() => setMetric(null), []);
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true); setError('');
|
||||||
|
fetchJson<FaultOverview>('/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 <div className="fault-page fault-overview-page">
|
||||||
|
<header className="fd-header">
|
||||||
|
<div><p className="fd-breadcrumb">羚牛氢能 BI / 故障分析</p><div className="fd-heading-line"><h1>羚牛氢能-故障管理</h1><p>实时掌握故障数量、闭环趋势与运维进展。</p></div></div>
|
||||||
|
<div className="fd-header-actions"><span>{data ? `更新时间:${new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}` : '正在读取故障台账'}</span><button aria-label="刷新故障统计" disabled={loading} onClick={() => setRevision(v => v + 1)}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} />刷新</button></div>
|
||||||
|
</header>
|
||||||
|
{navigation}
|
||||||
|
{error && <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div>}
|
||||||
|
<section className="fault-summary" aria-label="故障总统计" aria-busy={loading}>
|
||||||
|
<div className="fault-summary-heading"><h2>故障数据实时汇总</h2><span>{data?.date || '—'} · 北京时间</span></div>
|
||||||
|
<div className="fault-metrics">{metrics.map(key => {
|
||||||
|
const Icon = icons[key];
|
||||||
|
return <article key={key} className={`fault-metric fault-metric-${key}`}><button className="fault-metric-main" disabled={!data || loading || !!error} onClick={() => setMetric(key)} aria-label={`${FAULT_OVERVIEW_METRICS[key]} ${data?.totals[key] ?? '加载中'} 单,查看分析`}>
|
||||||
|
<div className="fault-metric-label"><span className="fault-metric-icon"><Icon size={19} /></span><span>{FAULT_OVERVIEW_METRICS[key]}</span><ArrowUpRight size={15} /></div>
|
||||||
|
<div className="fault-metric-count"><strong>{loading ? '—' : data?.totals[key].toLocaleString('zh-CN') ?? '—'}</strong><span>单</span></div>
|
||||||
|
<p>{FAULT_DEFINITIONS[key]}</p><span className="fault-metric-link">查看分析与明细 →</span>
|
||||||
|
</button>
|
||||||
|
{key === 'open' && <>
|
||||||
|
<button className="fault-overdue-subset" disabled={!data || loading || !!error} onClick={() => setMetric('overdue')} aria-label={`其中逾期 ${data?.totals.overdue ?? '加载中'} 单,查看分析`}>
|
||||||
|
<span><span className="fault-overdue-prefix">其中</span>逾期</span><strong>{loading ? '—' : data?.totals.overdue?.toLocaleString('zh-CN') ?? '—'}</strong><small>单</small>
|
||||||
|
</button>
|
||||||
|
<div className="fault-open-breakdown" aria-label="当前在办等于待处理加处理中">
|
||||||
|
{(['waiting', 'processing'] as const).map((part, index) => <Fragment key={part}>
|
||||||
|
{index > 0 && <span className="fault-composition-plus" aria-hidden="true">+</span>}
|
||||||
|
<button className={`fault-submetric fault-submetric-${part}`} disabled={!data || loading || !!error} onClick={() => setMetric(part)} aria-label={`${FAULT_OVERVIEW_METRICS[part]} ${data?.totals[part] ?? '加载中'} 单,查看分析`}>
|
||||||
|
<span>{FAULT_OVERVIEW_METRICS[part]}</span><strong>{loading ? '—' : data?.totals[part]?.toLocaleString('zh-CN') ?? '—'}</strong>
|
||||||
|
</button>
|
||||||
|
</Fragment>)}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</article>;
|
||||||
|
})}</div>
|
||||||
|
<p className="fault-summary-note">当前在办 = 待处理 + 处理中,其中逾期不重复累加;按处置截止时间统计,挂起停表,未设置截止时间不计入逾期。</p>
|
||||||
|
</section>
|
||||||
|
<FaultTrendCard refresh={revision} />
|
||||||
|
<FaultWeeklyReport refresh={revision} />
|
||||||
|
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
|
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
|
||||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
@@ -14,9 +15,11 @@ function initialRange() {
|
|||||||
}
|
}
|
||||||
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
|
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
|
||||||
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
|
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
|
||||||
export function FaultTrendCard({ refresh }: { refresh: number }) {
|
export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
|
||||||
const [range, setRange] = useState(initialRange);
|
const [range, setRange] = useState(() => period || initialRange());
|
||||||
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
|
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<FaultTrendResponse | null>(null);
|
const [data, setData] = useState<FaultTrendResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -28,22 +31,22 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true); setError(''); setData(null);
|
setLoading(true); setError(''); setData(null);
|
||||||
const params = new URLSearchParams({ ...range, granularity });
|
const params = new URLSearchParams({ ...range, granularity, ...scope });
|
||||||
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
|
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
|
||||||
.then(result => { if (!controller.signal.aborted) setData(result); })
|
.then(result => { if (!controller.signal.aborted) setData(result); })
|
||||||
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [range.start, range.end, granularity, revision, refresh]);
|
}, [range.start, range.end, granularity, revision, refresh, scopeKey]);
|
||||||
const drill = (status: FaultTrendStatus, period?: string) => {
|
const drill = (status: FaultTrendStatus, period?: string) => {
|
||||||
if (!data || loading) return;
|
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);
|
if (period) params.set('period', period);
|
||||||
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
|
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
|
||||||
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
|
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
|
||||||
};
|
};
|
||||||
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}>
|
return <section className="fault-trend fault-summary" aria-label="故障处理趋势" aria-busy={loading}>
|
||||||
<div className="fault-trend-heading"><h2><CalendarDays size={16} />故障处理趋势</h2><div className="fault-report-actions"><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'trend', data }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /><div role="group" aria-label="趋势时间粒度">{(['day', 'month'] as const).map(value => <button key={value} aria-pressed={granularity === value} onClick={() => setGranularity(value)}>{value === 'day' ? '按日' : '按月'}</button>)}</div></div></div>
|
<div className="fault-trend-heading"><h2><CalendarDays size={16} />故障处理趋势</h2><div className="fault-report-actions"><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'trend', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /><div role="group" aria-label="趋势时间粒度">{(['day', 'month'] as const).map(value => <button key={value} aria-pressed={granularity === value} onClick={() => setGranularity(value)}>{value === 'day' ? '按日' : '按月'}</button>)}</div></div></div>
|
||||||
<p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
|
<p className="fault-trend-definition">按提报日期统计新增故障,展示这批故障截至当前的闭环情况。新增 = 已闭环 + 未闭环。点击柱内色块,可按运维处置人、故障工单状态查看对应日期的故障。</p>
|
||||||
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} />
|
<FlowDateRangePicker dateLabel="故障提报时间" value={range} onChange={setRange} />
|
||||||
{error ? <div className="fault-message" role="alert">故障趋势加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障趋势…</div> : <>
|
{error ? <div className="fault-message" role="alert">故障趋势加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障趋势…</div> : <>
|
||||||
@@ -56,8 +59,8 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
|
|||||||
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
<YAxis allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||||
<Tooltip cursor={{ fill: '#f1f5f9', opacity: 0.7 }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', fontSize: 12 }} labelFormatter={(_, payload) => { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} />
|
<Tooltip cursor={{ fill: '#f1f5f9', opacity: 0.7 }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', fontSize: 12 }} labelFormatter={(_, payload) => { const point = payload?.[0]?.payload; return point ? `${point.period} · 新增 ${point.reported} 单` : ''; }} formatter={(value, name) => [`${value} 单`, name]} />
|
||||||
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: 11 }} />
|
||||||
<Bar dataKey="closed" name="已闭环" cursor="pointer" onClick={bar => { 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} />
|
<Bar dataKey="closed" name="已闭环" cursor="pointer" onClick={bar => { 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} />
|
||||||
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { 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} />
|
<Bar dataKey="unclosed" name="未闭环" cursor="pointer" onClick={bar => { 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} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
|
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ClipboardList, RefreshCw } from 'lucide-react';
|
import { ClipboardList, RefreshCw } from 'lucide-react';
|
||||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
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 metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
|
||||||
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
|
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
|
||||||
export function FaultWeeklyReport({ refresh }: { refresh: number }) {
|
export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
|
||||||
const [range, setRange] = useState(() => getWeeklyFlowRange());
|
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<FaultWeeklyResponse | null>(null);
|
const [data, setData] = useState<FaultWeeklyResponse | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -24,22 +27,22 @@ export function FaultWeeklyReport({ refresh }: { refresh: number }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
setLoading(true); setData(null); setError('');
|
setLoading(true); setData(null); setError('');
|
||||||
fetchJson<FaultWeeklyResponse>(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end })}`, { signal: controller.signal })
|
fetchJson<FaultWeeklyResponse>(`/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); })
|
.then(result => { if (!controller.signal.aborted) setData(result); })
|
||||||
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [range.start, range.end, revision, refresh]);
|
}, [range.start, range.end, revision, refresh, scopeKey]);
|
||||||
const drill = (metric: FaultWeeklyMetric, day?: string) => {
|
const drill = (metric: FaultWeeklyMetric, day?: string) => {
|
||||||
if (!data || loading) return;
|
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);
|
if (day) params.set('day', day);
|
||||||
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
|
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
|
||||||
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
|
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
|
||||||
};
|
};
|
||||||
return <section className="fault-weekly fault-trend fault-summary" aria-label="故障运维周报" aria-busy={loading}>
|
return <section className="fault-weekly fault-trend fault-summary" aria-label="故障运维周报" aria-busy={loading}>
|
||||||
<div className="fault-trend-heading"><h2><ClipboardList size={16} />故障运维周报</h2><div className="fault-report-actions"><span className="fault-weekly-unit">单位:单</span><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'weekly', data }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div>
|
<div className="fault-trend-heading"><h2><ClipboardList size={16} />故障运维周报</h2><div className="fault-report-actions"><span className="fault-weekly-unit">单位:单</span><AnalysisExportButton disabled={loading || !data || !!error} onExport={() => data ? downloadFaultReport({ kind: 'weekly', data, filterLabel: scopeLabel }, chartRef.current?.querySelector<SVGSVGElement>('svg.recharts-surface[role=application]') || undefined) : undefined} /></div></div>
|
||||||
<p className="fault-trend-definition">按日展示新增故障和解决故障。默认业务周为周六至周五,可自选日期。</p>
|
<p className="fault-trend-definition">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</p>
|
||||||
<FlowDateRangePicker dateLabel="提报 / 解决时间" value={range} onChange={setRange} />
|
<FlowDateRangePicker dateLabel="提报 / 解决时间" value={range} onChange={setRange} />
|
||||||
{error ? <div className="fault-message" role="alert">故障运维周报加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障运维周报…</div> : <>
|
{error ? <div className="fault-message" role="alert">故障运维周报加载失败,请稍后重试。<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : loading || !data ? <div className="fault-trend-loading" role="status"><RefreshCw size={16} className="animate-spin" />正在加载故障运维周报…</div> : <>
|
||||||
<div className="fault-trend-totals">{metrics.map(metric => <button key={metric} className={`fault-weekly-${metric}`} disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`周报${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看分析`}><strong>{data.totals[metric].toLocaleString()}</strong><span>{FAULT_WEEKLY_METRICS[metric]} ↗</span></button>)}</div>
|
<div className="fault-trend-totals">{metrics.map(metric => <button key={metric} className={`fault-weekly-${metric}`} disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`周报${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看分析`}><strong>{data.totals[metric].toLocaleString()}</strong><span>{FAULT_WEEKLY_METRICS[metric]} ↗</span></button>)}</div>
|
||||||
|
|||||||
@@ -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<string,string>)[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);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
|
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
|
||||||
import type { Cell, Worksheet } from 'exceljs';
|
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 }
|
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 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 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' ? '按月' : '按日'} · 单位:单`);
|
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}` } };
|
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;
|
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 sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
|
||||||
const cards = trend
|
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: '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 }]
|
||||||
|
|||||||
@@ -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<string,unknown>={})=>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);
|
||||||
|
});
|
||||||
@@ -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<string, string>) {
|
||||||
|
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<string, string>) {
|
||||||
|
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<string, string>, 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<string, string>, 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<string, ScopedFault[]>();
|
||||||
|
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},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { dashboardAnalysis, faultDashboard, filterDashboardRows, parseDashboardQuery } from './dashboard.js';
|
||||||
import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js';
|
import { FAULT_OVERVIEW_METRICS, type FaultOverviewMetric } from '../../../shared/assets/faults.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import { analyzeFaults, faultOverview, faultAnalysis, parseFaultQuery, visibleFaults } from './model.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) {
|
export function createFaultRouter(load = loadFaults) {
|
||||||
const app = new Hono<{ Variables: { user: AuthUser } }>();
|
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) {
|
for (const path of ['/trend', '/trend-analysis'] as const) {
|
||||||
app.get(path, async c => {
|
app.get(path, async c => {
|
||||||
const user = c.get('user');
|
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); }
|
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
||||||
try {
|
try {
|
||||||
const data = await load();
|
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');
|
c.header('Cache-Control', 'no-store');
|
||||||
return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt));
|
return c.json(path === '/trend' ? faultTrend(rows, query, data.updatedAt) : faultTrendAnalysis(rows, query, data.updatedAt));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,7 +48,7 @@ export function createFaultRouter(load = loadFaults) {
|
|||||||
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
catch (error) { return c.json({ error: (error as Error).message }, 400); }
|
||||||
try {
|
try {
|
||||||
const data = await load();
|
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');
|
c.header('Cache-Control', 'no-store');
|
||||||
return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt));
|
return c.json(path === '/weekly-report' ? faultWeeklyReport(rows, query, data.updatedAt) : faultWeeklyAnalysis(rows, query, data.updatedAt));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type RawFault = Record<string, unknown>;
|
|||||||
export interface ScopedFault extends FaultRecord {
|
export interface ScopedFault extends FaultRecord {
|
||||||
subject: string; departmentName: string; managerId: string; missingVehicle: boolean;
|
subject: string; departmentName: string; managerId: string; missingVehicle: boolean;
|
||||||
assetCell: AssetAnalysisCell;
|
assetCell: AssetAnalysisCell;
|
||||||
|
vehicleKey?: string;
|
||||||
}
|
}
|
||||||
export function normalizeFaultStatus(value: unknown): string {
|
export function normalizeFaultStatus(value: unknown): string {
|
||||||
const status = String(value ?? '').trim();
|
const status = String(value ?? '').trim();
|
||||||
@@ -77,6 +78,10 @@ export function mapFault(row: RawFault, directory: Map<string, string>): ScopedF
|
|||||||
manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1,
|
manager: text(row.business_manager) || '未分配', customer: customerKey, abnormalType: '不适用', abnormalAge: 'unknown', quantity: 1,
|
||||||
};
|
};
|
||||||
return {
|
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<string, string>)[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) || '未关联车辆',
|
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,
|
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,
|
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,
|
quality: { missingReport: rows.filter(r => !r.reportedAt).length,
|
||||||
invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length,
|
invalidChronology: rows.filter(r => r.reportedAt && r.archivedAt && r.archivedAt < r.reportedAt).length,
|
||||||
missingVehicle: rows.filter(r => r.missingVehicle).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 date = faultBusinessDate(updatedAt);
|
||||||
const selected = selectFaultMetric(rows, metric, date, updatedAt);
|
const selected = selectFaultMetric(rows, metric, date, updatedAt);
|
||||||
return { date, updatedAt, metric, customerLabels: Object.fromEntries(selected.map(row => [row.customer, row.customerLabel])),
|
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 })) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// OneOS fault ledger: latest delivery first; do not reuse the active-asset WHERE clause.
|
||||||
export const FAULT_SQL = `SELECT
|
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,
|
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.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.archived_at, '%Y-%m-%d %H:%i:%s') archived_at,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface FaultRecord {
|
|||||||
city: string; status: string; inventory: string;
|
city: string; status: string; inventory: string;
|
||||||
customer: string; customerLabel: string; department: string;
|
customer: string; customerLabel: string; department: string;
|
||||||
handlers: FaultOption[];
|
handlers: FaultOption[];
|
||||||
|
level?: string; categories?: string[]; progress?: string;
|
||||||
}
|
}
|
||||||
export interface FaultQuery {
|
export interface FaultQuery {
|
||||||
start: string; end: string; groupBy: FaultDimension;
|
start: string; end: string; groupBy: FaultDimension;
|
||||||
@@ -63,3 +64,18 @@ export interface FaultWeeklyResponse {
|
|||||||
totals: Record<FaultWeeklyMetric, number>;
|
totals: Record<FaultWeeklyMetric, number>;
|
||||||
days: (Record<FaultWeeklyMetric, number> & { date: string })[];
|
days: (Record<FaultWeeklyMetric, number> & { 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<Record<FaultDashboardDimension, string>>;
|
||||||
|
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<FaultDashboardDimension, FaultOption[]>;
|
||||||
|
distributions: Record<'region' | 'level' | 'customer' | 'model', FaultDistribution[]>;
|
||||||
|
matrix: { models: FaultOption[]; categories: FaultOption[]; cells: { model: string; category: string; count: number }[]; totals: Record<string, number> };
|
||||||
|
focus: FaultAnalysisRecord[];
|
||||||
|
quality: { missingLevel: number; missingCategory: number; missingVehicle: number; missingDeadline: number; missingReport: number };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user