feat(assets): 完善故障风险多维分析与管理总览
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-10-16 12:38:52 +08:00
parent 4dec824285
commit f107e96d3c
17 changed files with 592 additions and 82 deletions
@@ -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;
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
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 }) {
const ref = useRef<HTMLDivElement>(null);
useDialogFocus(true, ref, onClose);
@@ -38,7 +38,7 @@ export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultO
const controller = new AbortController();
setError(''); setData(null);
fetchJson<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();
}, [metric, revision, source?.url]);
const records = useMemo(() => data?.rows || [], [data]);
+67 -60
View File
@@ -1,64 +1,71 @@
import { Fragment, useCallback, useEffect, useState } from 'react';
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { ArrowUpRight, CarFront, ClipboardList, Clock3, Filter, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
import { fetchJson } from '../../../auth/api-client';
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults';
import { FaultAnalysisModal } from './FaultAnalysisModal';
import { FaultWeeklyReport } from './FaultWeeklyReport';
import { FaultTrendCard } from './FaultTrendCard';
import { FAULT_DASHBOARD_FILTERS, type FaultAnalysisResponse, type FaultDashboardDimension, type FaultDashboardFilters, type FaultDashboardResponse, type FaultDashboardScope } from '../../../shared/assets/faults';
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
import { FaultOverview } from './FaultOverview';
import { DashboardPanels, DashboardTicketTable, type DashboardDrill } from './DashboardPanels';
import { downloadDashboard } from './dashboard-export';
import './fault-management.css';
export const FAULT_DEFINITIONS = {
reported: '按今日提报时间统计', closed: '按今日解决时间统计',
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账',
};
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList };
const metrics = ['reported', 'closed', 'open', 'ledger'] as const;
export default function FaultManagement() {
const [data, setData] = useState<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(setData).catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [revision]);
return <div className="fault-page">
<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>
{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>
import './dashboard.css';
const tabs = ['管理总览','多维分析','故障明细'] as const;
function initialRange(){const end=new Date(Date.now()+8*3600000).toISOString().slice(0,10);return {start:new Date(Date.parse(end)-29*86400000).toISOString().slice(0,10),end};}
export default function FaultManagement(){
const [range,setRange]=useState(initialRange),[filters,setFilters]=useState<FaultDashboardFilters>({});
const [applied,setApplied]=useState(()=>({...initialRange(),filters:{} as FaultDashboardFilters}));
const [tab,setTab]=useState<typeof tabs[number]>('管理总览'),[more,setMore]=useState(false);
const [data,setData]=useState<FaultDashboardResponse|null>(null),[loading,setLoading]=useState(true),[error,setError]=useState('');
const [revision,setRevision]=useState(0),[source,setSource]=useState<FaultAnalysisSource|null>(null);
const [detailScope,setDetailScope]=useState<FaultDashboardScope>('cohort'),[detailData,setDetailData]=useState<FaultAnalysisResponse|null>(null),[detailError,setDetailError]=useState(''),[detailLoading,setDetailLoading]=useState(false);
const [search,setSearch]=useState(''),[limit,setLimit]=useState(50);
const close=useCallback(()=>setSource(null),[]);
const query=new URLSearchParams({start:applied.start,end:applied.end,...applied.filters}).toString();
useEffect(()=>{if(tab==='管理总览')return;const c=new AbortController();setLoading(true);setError('');fetchJson<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(()=>{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 drill:DashboardDrill=(title,scope='cohort',extra={})=>{if(loading||error||!data)return;const params=new URLSearchParams({start:data.start,end:data.end,...applied.filters,...extra,scope});setSource({url:`/api/asset-faults/dashboard-analysis?${params}`,title,periodLabel:scope==='cohort'?`${data.start} 至 ${data.end} · 按提报日期`:scope==='reported'||scope==='closed'?'北京时间今日':'全部提报日期 · 当前状态',defaultDimensions:extra.id?[]:['handler','faultStatus'],initialDetails:!!extra.id,definition:scope==='cohort'?'按提报日期筛选同批故障,已归档为已闭环,其余状态为未闭环。':scope==='overdue'?'逾期仅包含待处理、处理中且处置截止时间早于数据时间的故障;挂起、未设置截止时间不计入。':scope==='reported'?'按北京时间今日提报时间统计。':scope==='closed'?'按北京时间今日解决时间统计,不限制提报日期。':'不限制提报日期,按当前状态及已应用条件统计;当前在办 = 待处理 + 处理中。'});};
const t=data?.totals;
const detailRows=(detailData?.rows||[]).filter(r=>`${r.code} ${r.plate} ${r.description} ${r.handlers.map(h=>h.label).join(' ')}`.toLowerCase().includes(search.trim().toLowerCase())).sort((a,b)=>(b.reportedAt||'').localeCompare(a.reportedAt||'')||b.id.localeCompare(a.id,'en',{numeric:true}));
const dirty=range.start!==applied.start||range.end!==applied.end||JSON.stringify(filters)!==JSON.stringify(applied.filters);
const count=(n?:number)=>loading?'—':n?.toLocaleString('zh-CN')??'—';
const select=(key:FaultDashboardDimension)=><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>;
const showAllOpen=()=>{setDetailScope('open');setTab('故障明细');setSearch('');};
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>;
if(tab==='管理总览')return <FaultOverview navigation={navigation}/>;
return <div className="fault-page fd-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 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>
{navigation}
<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>
<p className="fd-scope-note">期间指标按提报日期统计;当前在办不限制提报日期,状态截至更新时间。{dirty&&<strong>条件已修改,点击查询后生效。</strong>}</p>
{error&&<div className="fault-message" role="alert">{error}<button onClick={()=>setRevision(v=>v+1)}>重试</button></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}>
{tab==='多维分析'&&<>
<section className="fd-kpis fd-kpis-five" aria-label="故障总统计">
<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>
<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>
<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>
<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>
<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>
</section>
<FaultTrendCard refresh={revision} />
<FaultWeeklyReport refresh={revision} />
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
</div>;
<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>
<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>
{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>;
}
@@ -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>;
}
+11 -8
View File
@@ -1,3 +1,4 @@
import type { FaultDashboardFilters } from '../../../shared/assets/faults';
import { useCallback, useEffect, useRef, useState } from 'react';
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -14,9 +15,11 @@ function initialRange() {
}
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
export function FaultTrendCard({ refresh }: { refresh: number }) {
const [range, setRange] = useState(initialRange);
export function FaultTrendCard({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => period || initialRange());
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
const scopeKey = new URLSearchParams(scope).toString();
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
const [data, setData] = useState<FaultTrendResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -28,22 +31,22 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
useEffect(() => {
const controller = new AbortController();
setLoading(true); setError(''); setData(null);
const params = new URLSearchParams({ ...range, granularity });
const params = new URLSearchParams({ ...range, granularity, ...scope });
fetchJson<FaultTrendResponse>(`/api/asset-faults/trend?${params}`, { signal: controller.signal })
.then(result => { if (!controller.signal.aborted) setData(result); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [range.start, range.end, granularity, revision, refresh]);
}, [range.start, range.end, granularity, revision, refresh, scopeKey]);
const drill = (status: FaultTrendStatus, period?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status });
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status, ...scope });
if (period) params.set('period', period);
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
};
return <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>
<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> : <>
@@ -56,8 +59,8 @@ export function FaultTrendCard({ refresh }: { refresh: number }) {
<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]} />
<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="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="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="#ff981a" stackId="faults" maxBarSize={34} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</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 { ClipboardList, RefreshCw } from 'lucide-react';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -11,8 +12,10 @@ import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisMod
const metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
export function FaultWeeklyReport({ refresh }: { refresh: number }) {
const [range, setRange] = useState(() => getWeeklyFlowRange());
export function FaultWeeklyReport({ refresh, scope = {}, scopeLabel = '', period }: { refresh: number; scope?: FaultDashboardFilters; scopeLabel?: string; period?: { start: string; end: string } }) {
const [range, setRange] = useState(() => period || getWeeklyFlowRange());
const scopeKey = new URLSearchParams(scope).toString();
useEffect(() => { if (period) setRange(period); }, [period?.start, period?.end]);
const [data, setData] = useState<FaultWeeklyResponse | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
@@ -24,22 +27,22 @@ export function FaultWeeklyReport({ refresh }: { refresh: number }) {
useEffect(() => {
const controller = new AbortController();
setLoading(true); setData(null); setError('');
fetchJson<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); })
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [range.start, range.end, revision, refresh]);
}, [range.start, range.end, revision, refresh, scopeKey]);
const drill = (metric: FaultWeeklyMetric, day?: string) => {
if (!data || loading) return;
const params = new URLSearchParams({ start: data.start, end: data.end, metric });
const params = new URLSearchParams({ start: data.start, end: data.end, metric, ...scope });
if (day) params.set('day', day);
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
};
return <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>
<p className="fault-trend-definition">按日展示新增故障和解决故障。默认业务周为周六至周五,可自选日期。</p>
<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">按日展示新增故障和解决故障。{period ? '统计周期沿用查询范围,可在下方单独调整。' : '默认业务周为周六至周五,可自选日期。'}</p>
<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> : <>
<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);
}
+125
View File
@@ -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; }
}
+2 -1
View File
@@ -1,7 +1,7 @@
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
import type { Cell, Worksheet } from 'exceljs';
export type FaultReport = { kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse };
export type FaultReport = ({ kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse }) & { filterLabel?: string };
interface ReportChartImage { dataUrl: string; width: number; height: number }
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
@@ -39,6 +39,7 @@ export async function createFaultReportWorkbook(report: FaultReport, chartImage?
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
if (report.filterLabel) { const filter = merged(sheet, 4, 1, columns, `筛选条件:${report.filterLabel}`); filter.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; filter.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(4).height = 32; }
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
const cards = trend
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]