feat(assets): 完成故障维修运维 BI 与 Excel 报表导出
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, ChevronDown, Filter, Search, X } from 'lucide-react';
|
||||
|
||||
export type MobileDimension = { key: string; label: string; options: { value: string; label: string; count?: number }[] };
|
||||
type Filters = Partial<Record<string, string[]>>;
|
||||
type Props = {
|
||||
available: MobileDimension[];
|
||||
dimensions: string[];
|
||||
filters: Filters;
|
||||
defaults: string[];
|
||||
apply: (dimensions: string[], filters: Filters) => void;
|
||||
};
|
||||
|
||||
/** Draft all mobile settings together; cancelling never changes the current result. */
|
||||
export function MobileAnalysisControls({ available, dimensions, filters, defaults, apply }: Props) {
|
||||
const [mode, setMode] = useState<'group' | 'filter' | null>(null);
|
||||
const [draftDimensions, setDraftDimensions] = useState(dimensions);
|
||||
const [draftFilters, setDraftFilters] = useState(filters);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const id = useId();
|
||||
const selected = available.filter(d => filters[d.key]?.length);
|
||||
const editor = available.find(d => d.key === editing);
|
||||
const options = editor?.options.filter(o => o.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())) || [];
|
||||
const close = () => { setMode(null); trigger.current?.focus(); };
|
||||
const open = (next: 'group' | 'filter', button: HTMLButtonElement) => {
|
||||
trigger.current = button; setDraftDimensions([...dimensions]); setDraftFilters({ ...filters });
|
||||
setEditing(null); setQuery(''); setMode(next);
|
||||
requestAnimationFrame(() => panel.current?.focus());
|
||||
};
|
||||
const toggle = (key: string, value: string) => {
|
||||
const values = draftFilters[key] || [];
|
||||
setDraftFilters({ ...draftFilters, [key]: values.includes(value) ? values.filter(v => v !== value) : [...values, value] });
|
||||
};
|
||||
return <div className="analysis-mobile-controls">
|
||||
<div className="analysis-mobile-dimension-heading"><strong>分析维度</strong><span>可多选 · 左右滑动</span><button onClick={e => open('group', e.currentTarget)} aria-expanded={mode === 'group'} aria-controls={id}>分组设置</button></div>
|
||||
<div className="analysis-mobile-toolbar">
|
||||
<div className="analysis-dimension-strip" role="group" aria-label="分析维度">
|
||||
{available.map(d => <button key={d.key} type="button" aria-pressed={dimensions.includes(d.key)} onClick={() => apply(dimensions.includes(d.key) ? dimensions.filter(key => key !== d.key) : [...dimensions, d.key], filters)}>{d.label}{dimensions.includes(d.key) && <small>{dimensions.indexOf(d.key) + 1}</small>}</button>)}
|
||||
<button type="button" aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button>
|
||||
</div>
|
||||
<button type="button" onClick={e => open('filter', e.currentTarget)} aria-expanded={mode === 'filter'} aria-controls={id}><Filter size={16} />筛选{selected.length ? ` ${selected.length}` : ''}</button>
|
||||
</div>
|
||||
{!!selected.length && <div className="analysis-mobile-chips" aria-label="已选筛选条件">{selected.map(d => <button key={d.key} title={(filters[d.key] || []).map(v => d.options.find(o => o.value === v)?.label || v).join('、')} aria-label={`移除${d.label}筛选`} onClick={() => { const next = { ...filters }; delete next[d.key]; apply(dimensions, next); }}><span>{d.label}:{filters[d.key]?.length === 1 ? d.options.find(o => o.value === filters[d.key]?.[0])?.label || filters[d.key]?.[0] : `${filters[d.key]?.length}项`}</span><X size={14} /></button>)}</div>}
|
||||
{mode && <div className="analysis-settings-backdrop" onClick={e => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div ref={panel} id={id} role="dialog" aria-modal="true" aria-label={mode === 'group' ? '分组设置' : '筛选条件'} tabIndex={-1} className="analysis-settings-panel" onKeyDown={e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (editing) { setEditing(null); setQuery(''); } else close(); }
|
||||
if (e.key === 'Tab') {
|
||||
e.stopPropagation();
|
||||
const items = Array.from(panel.current?.querySelectorAll<HTMLElement>('button:not([disabled]),input') || []).filter(el => el.getClientRects().length);
|
||||
const first = items[0], last = items.at(-1);
|
||||
if (e.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) { e.preventDefault(); last?.focus(); }
|
||||
else if (!e.shiftKey && (document.activeElement === last || document.activeElement === panel.current)) { e.preventDefault(); first?.focus(); }
|
||||
}
|
||||
}}>
|
||||
<header><button aria-label={editing ? '返回筛选条件' : '取消设置'} onClick={() => editing ? (setEditing(null), setQuery('')) : close()}><ArrowLeft size={20} /></button><h3>{editor?.label || (mode === 'group' ? '分组设置' : '筛选条件')}</h3><button aria-label="关闭设置" onClick={close}><X size={20} /></button></header>
|
||||
{editor && <label className="analysis-settings-search"><Search size={17} /><input aria-label={`搜索${editor.label}`} placeholder={`搜索${editor.label}`} value={query} onChange={e => setQuery(e.target.value)} /></label>}
|
||||
<div className="analysis-settings-content">
|
||||
{mode === 'group' ? <><p>可选择多个维度,按选择顺序组合统计。</p><div className="analysis-settings-groups">{available.map(d => <button key={d.key} aria-pressed={draftDimensions.includes(d.key)} onClick={() => setDraftDimensions(draftDimensions.includes(d.key) ? draftDimensions.filter(v => v !== d.key) : [...draftDimensions, d.key])}><span>{d.label}</span>{draftDimensions.includes(d.key) && <span className="analysis-dimension-order">{draftDimensions.indexOf(d.key) + 1}</span>}</button>)}</div><button className="analysis-settings-row" aria-pressed={!draftDimensions.length} onClick={() => setDraftDimensions([])}>仅汇总,不分组{!draftDimensions.length && <Check size={18} />}</button></> : editor ? <>
|
||||
<div className="analysis-settings-select"><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [...new Set([...(draftFilters[editor.key] || []), ...options.map(o => o.value)])] })}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraftFilters({ ...draftFilters, [editor.key]: [] })}>清除选择</button><span>{draftFilters[editor.key]?.length || 0} 项已选</span></div>
|
||||
{options.map(o => <label className="analysis-settings-option" key={o.value}><input type="checkbox" checked={draftFilters[editor.key]?.includes(o.value) || false} onChange={() => toggle(editor.key, o.value)} /><span>{o.label}</span>{o.count !== undefined && <small>{o.count.toLocaleString()}</small>}</label>)}
|
||||
{!options.length && <p>没有匹配选项,可调整搜索。</p>}
|
||||
</> : <><p>同一维度多选取并集,不同维度同时满足。未选择表示不限。</p>{available.map(d => <button className="analysis-settings-row" key={d.key} onClick={() => { setEditing(d.key); setQuery(''); }}><span>{d.label}</span><small>{draftFilters[d.key]?.length ? `已选 ${draftFilters[d.key]?.length} 项` : '不限'}</small><ChevronDown size={16} /></button>)}</>}
|
||||
</div>
|
||||
<footer><button onClick={() => mode === 'group' ? setDraftDimensions([...defaults]) : setDraftFilters({})}>重置{mode === 'group' ? '分组' : '筛选'}</button><button className="analysis-settings-apply" onClick={() => { const clean = Object.fromEntries(Object.entries(draftFilters).filter(([, values]) => values?.length)); apply(draftDimensions, clean); close(); }}>查看结果</button></footer>
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
type Row = { key: string; label: string; values: ReactNode[]; count: number; share: number; note?: ReactNode };
|
||||
type Props = {
|
||||
dimensions: string[];
|
||||
rows: Row[];
|
||||
unit: string;
|
||||
merged: boolean;
|
||||
setMerged: (value: boolean) => void;
|
||||
spans: number[][];
|
||||
parentTotals: number[][];
|
||||
onDetails: (index: number) => void;
|
||||
onFilter?: (index: number) => void;
|
||||
};
|
||||
|
||||
/** Retain table semantics and actual parent row spans at narrow viewport widths. */
|
||||
export function MobileGroupedTable({ dimensions, rows, unit, merged, setMerged, spans, parentTotals, onDetails, onFilter }: Props) {
|
||||
const multiple = dimensions.length > 1;
|
||||
return <section className={`analysis-mobile-grid${onFilter ? '' : ' without-filter'}`}>
|
||||
<div className="analysis-mobile-grid-heading"><strong>分组统计</strong>{multiple ? <label><input type="checkbox" role="switch" checked={merged} onChange={e => setMerged(e.target.checked)} /><span>合并单元格</span></label> : <span>数量 · 占比</span>}</div>
|
||||
{multiple && <p className="analysis-mobile-grid-hint">左右滑动查看完整表格{merged ? ' · 相同上级已合并' : ' · 每行独立显示'}</p>}
|
||||
<div className="analysis-mobile-grid-scroll" role="region" aria-label="分组统计表,可左右滑动" tabIndex={0}>
|
||||
<table style={{ minWidth: multiple ? `${dimensions.length * 108 + 192}px` : '100%' }}>
|
||||
<thead><tr>{dimensions.length ? dimensions.map((label, i) => <th scope="col" key={i}>{label}</th>) : <th scope="col">范围</th>}<th scope="col" className="mobile-grid-number">数量<small>({unit})</small></th><th scope="col" className="mobile-grid-share">占比</th><th scope="col" className="mobile-grid-actions">操作</th></tr></thead>
|
||||
<tbody>{rows.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex]?.[0] > 0 ? 'mobile-grid-group-start' : undefined}>
|
||||
{dimensions.length ? row.values.map((value, colIndex) => {
|
||||
const span = merged ? spans[rowIndex]?.[colIndex] ?? 1 : 1;
|
||||
if (!span) return null;
|
||||
const parent = merged && colIndex < dimensions.length - 1;
|
||||
return <td key={colIndex} rowSpan={span} className={parent ? 'mobile-grid-parent' : 'mobile-grid-label'}>{value}{parent && <small>合计 <b>{parentTotals[rowIndex]?.[colIndex]?.toLocaleString('zh-CN')}</b> {unit}</small>}</td>;
|
||||
}) : <td className="mobile-grid-label">当前结果</td>}
|
||||
<td className="mobile-grid-number"><strong>{row.count.toLocaleString('zh-CN')}</strong>{row.note && <small>{row.note}</small>}</td>
|
||||
<td className="mobile-grid-share"><span>{row.share.toFixed(1)}%</span><i aria-hidden="true"><i style={{ width: `${row.share}%` }} /></i></td>
|
||||
<td className="mobile-grid-actions"><div>{onFilter && dimensions.length > 0 && <button className="mobile-grid-filter" aria-label={`筛选分组 ${row.label}`} title="筛选此组" onClick={() => onFilter(rowIndex)}><Filter size={15} /></button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => onDetails(rowIndex)}>明细</button></div></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* Shared mobile presentation for asset statistics, filters and vehicle details. */
|
||||
@media (max-width: 767px) {
|
||||
.asset-analysis-overlay, .bi-flow-modal-backdrop { padding: 0; align-items: stretch; }
|
||||
.asset-analysis-dialog, .bi-flow-modal { width: 100%; max-width: none; height: 100dvh; max-height: 100dvh; min-height: 0; border-radius: 0; font-size: 13px; }
|
||||
.analysis-header, .bi-flow-modal-header { padding: max(6px, env(safe-area-inset-top)) 10px 6px; background: #fff; color: var(--color-slate-800, #1e293b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||
.analysis-header { gap: 8px; }
|
||||
.analysis-header > div { flex: 1; min-width: 0; }
|
||||
.analysis-header h2, .bi-flow-modal-header h3 { font-size: 15px; }
|
||||
.analysis-header h2 > svg { display: none; }
|
||||
.analysis-header h2, .bi-flow-modal-header h3 { border-left: 3px solid var(--color-blue-600, #2563eb); padding-left: 8px; }
|
||||
.analysis-header p, .bi-flow-modal-header p { font-size: 11px; line-height: 1.5; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-header button, .bi-flow-modal-close, .bi-flow-mobile-back { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; padding: 0; color: var(--color-slate-500, #64748b); background: var(--color-slate-50, #f8fafc); border-radius: 8px; }
|
||||
.analysis-header button:hover, .bi-flow-modal-close:hover { background: var(--color-blue-50, #eff6ff); }
|
||||
.analysis-scroll, .bi-flow-modal-body { padding: 12px 16px max(16px, env(safe-area-inset-bottom)); background: var(--app-bg, #f4f7fb); }
|
||||
.analysis-result-bar { padding: 10px 14px; gap: 8px; font-size: 12px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-radius: 10px; color: var(--color-slate-600, #475569); margin-bottom: 10px; }
|
||||
.analysis-result-bar strong { font-size: 28px; margin: 0 4px; color: var(--color-blue-600, #2563eb); }
|
||||
.analysis-result-dimensions { display: none; }
|
||||
.analysis-inventory-subsets { gap: 5px 12px; font-size: 11px; margin: -11px 0 8px; padding: 8px 14px; background: var(--color-blue-50, #eff6ff); border: 1px solid var(--color-blue-100, #dbeafe); border-top: 0; border-radius: 0 0 10px 10px; }
|
||||
.analysis-inventory-subsets small { width: 100%; font-size: 11px; }
|
||||
.analysis-mobile-toolbar { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 8px; }
|
||||
.analysis-mobile-toolbar > button { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; border: 1px solid #d8e0eb; border-radius: 9px; padding: 8px 12px; background: #f8fafc; color: #334155; font-size: 13px; }
|
||||
.analysis-mobile-toolbar > button:first-child { justify-content: space-between; }
|
||||
.analysis-mobile-toolbar > button > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.analysis-mobile-toolbar svg { flex-shrink: 0; }
|
||||
.analysis-mobile-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.analysis-mobile-chips button { display: flex; align-items: center; gap: 6px; max-width: 100%; min-height: 44px; padding: 6px 10px; border-radius: 8px; color: var(--color-blue-700, #1d4ed8); background: #eff6ff; font-size: 11px; }
|
||||
.analysis-mobile-chips span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.analysis-mobile-chips svg { flex-shrink: 0; }
|
||||
.analysis-overview-tools > p, .analysis-overview-tools .analysis-merge-toggle { display: none; }
|
||||
.analysis-overview-tools.is-empty { display: none; }
|
||||
.analysis-overview-tools .analysis-table-options { width: 100%; justify-content: space-between; gap: 6px; }
|
||||
.analysis-table-tools { margin: 8px 0; }
|
||||
.analysis-table-tools label, .analysis-table-tools select, .analysis-export-action button { font-size: 11px; }
|
||||
.analysis-table-tools select, .analysis-export-action button { min-height: 44px; }
|
||||
.analysis-table-tools h3 { font-size: 14px; }
|
||||
.analysis-table-tools h3 small { font-size: 12px; }
|
||||
.analysis-table-total { border: 0; border-radius: 0; background: none; padding: 8px 0; font-size: 11px; line-height: 1.6; }
|
||||
.analysis-table-total > span:last-child { display: none; }
|
||||
.analysis-mobile-detail-scope { font-size: 12px; color: #64748b; margin-bottom: 8px; }
|
||||
.analysis-search { width: 100%; min-height: 44px; padding: 9px 12px; border-radius: 8px; }
|
||||
.analysis-search input { font-size: 16px; width: 100%; }
|
||||
.analysis-vehicle-cards { display: grid; gap: 8px; margin-top: 10px; }
|
||||
.analysis-vehicle-card { min-width: 0; padding: 12px; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
|
||||
.analysis-vehicle-card header { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 6px 12px; justify-content: space-between; }
|
||||
.analysis-vehicle-card h4 { color: var(--color-blue-700, #1d4ed8); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.analysis-vehicle-card header > span { font-size: 11px; padding: 3px 6px; border-radius: 4px; color: #475569; background: #f1f5f9; }
|
||||
.analysis-vehicle-card > p { font-size: 12px; color: #64748b; line-height: 1.6; margin-top: 6px; overflow-wrap: anywhere; }
|
||||
.analysis-vehicle-card summary { display: flex; align-items: center; min-height: 44px; font-size: 12px; color: var(--color-blue-600, #2563eb); cursor: pointer; }
|
||||
.analysis-vehicle-card summary::after { content: '+'; margin-left: auto; }
|
||||
.analysis-vehicle-card details[open] summary::after { content: '−'; }
|
||||
.analysis-vehicle-card dl > div { display: grid; grid-template-columns: 90px minmax(0,1fr); gap: 10px; padding: 4px 0; font-size: 12px; line-height: 1.6; }
|
||||
.analysis-vehicle-card dt { color: #64748b; }
|
||||
.analysis-vehicle-card dd { color: #334155; overflow-wrap: anywhere; }
|
||||
.analysis-footer { padding: 0 16px max(0px, env(safe-area-inset-bottom)); background: #fff; font-size: 11px; }
|
||||
.analysis-footer details { width: 100%; max-height: 28dvh; overflow-y: auto; }
|
||||
.analysis-footer summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; }
|
||||
.analysis-footer details p { margin-bottom: 10px; line-height: 1.6; }
|
||||
.analysis-message { font-size: 13px; min-height: 240px; }
|
||||
.analysis-message h3 { font-size: 15px; }
|
||||
.analysis-definition { font-size: 11px; }
|
||||
.analysis-details .text-base { font-size: 14px; }
|
||||
.analysis-details .text-sm { font-size: 13px; }
|
||||
.analysis-details .text-xs { font-size: 11px; }
|
||||
.analysis-details .space-y-3.px-3 { padding: 0; }
|
||||
.analysis-details .space-y-3 article { box-shadow: none; }
|
||||
.analysis-settings-backdrop { position: fixed; inset: 0; z-index: 30; background: rgb(15 23 42 / 38%); display: flex; align-items: flex-end; }
|
||||
.analysis-settings-panel { display: flex; flex-direction: column; width: 100%; height: min(680px, 90dvh); min-height: 0; background: #fff; border-radius: 12px 12px 0 0; color: #334155; box-shadow: 0 -12px 36px #0f172a20; outline: none; }
|
||||
.analysis-settings-panel header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
.analysis-settings-panel header h3 { flex: 1; font-size: 15px; font-weight: 600; }
|
||||
.analysis-settings-panel header button { width: 44px; height: 44px; display: grid; place-items: center; }
|
||||
.analysis-settings-search { display: flex; align-items: center; gap: 8px; margin: 12px 16px 0; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; }
|
||||
.analysis-settings-search input { min-width: 0; width: 100%; font-size: 16px; outline: none; }
|
||||
.analysis-settings-content { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 12px 16px; }
|
||||
.analysis-settings-content > p { color: #64748b; font-size: 12px; line-height: 1.6; margin: 0 0 12px; }
|
||||
.analysis-settings-groups { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px; margin-bottom: 18px; }
|
||||
.analysis-settings-groups button { min-height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border: 1px solid #d8e0eb; border-radius: 8px; font-size: 13px; }
|
||||
.analysis-settings-groups button[aria-pressed=true] { color: var(--color-blue-700, #1d4ed8); background: #eff6ff; border-color: #93c5fd; }
|
||||
.analysis-settings-groups .analysis-dimension-order { width: 20px; height: 20px; font-size: 11px; }
|
||||
.analysis-settings-row { display: flex; gap: 10px; width: 100%; align-items: center; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #e2e8f0; text-align: left; font-size: 13px; }
|
||||
.analysis-settings-row > span { flex: 1; }
|
||||
.analysis-settings-row > small { font-size: 12px; color: #64748b; }
|
||||
.analysis-settings-select { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 12px; }
|
||||
.analysis-settings-select button { min-height: 44px; color: var(--color-blue-600, #2563eb); }
|
||||
.analysis-settings-select > span { margin-left: auto; color: #64748b; }
|
||||
.analysis-settings-option { display: flex; align-items: center; gap: 12px; min-height: 48px; padding: 8px 0; border-bottom: 1px solid #f1f5f9; font-size: 13px; }
|
||||
.analysis-settings-option input { width: 18px; height: 18px; accent-color: var(--color-blue-600, #2563eb); flex-shrink: 0; }
|
||||
.analysis-settings-option span { flex: 1; overflow-wrap: anywhere; }
|
||||
.analysis-settings-option small { color: #64748b; font-size: 12px; }
|
||||
.analysis-settings-panel footer { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #e2e8f0; }
|
||||
.analysis-settings-panel footer button { min-height: 46px; border-radius: 9px; background: #f1f5f9; font-size: 13px; }
|
||||
.analysis-settings-panel footer .analysis-settings-apply { background: var(--color-blue-600, #2563eb); color: #fff; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.bi-flow-modal-header-row { align-items: center; gap: 8px; }
|
||||
.bi-flow-modal-header-row > div { flex: 1; min-width: 0; }
|
||||
.bi-flow-modal-summary { margin: 0 0 12px; background: var(--color-blue-50, #eff6ff); border-color: var(--color-blue-100, #dbeafe); }
|
||||
.bi-flow-modal-summary > div { padding: 10px 8px; gap: 6px; }
|
||||
.bi-flow-modal-summary span, .bi-flow-modal strong small { font-size: 11px; }
|
||||
.bi-flow-modal-summary strong { font-size: 23px; }
|
||||
.bi-flow-modal-section-heading { margin: 12px 0 4px; }
|
||||
.bi-flow-modal-section-heading h4 { font-size: 14px; }
|
||||
.bi-flow-modal-section-heading p { font-size: 11px; }
|
||||
.bi-flow-modal-empty { font-size: 13px; line-height: 1.7; color: #64748b; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.analysis-details .space-y-3 dt { font-size: 11px; line-height: 1.8; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.analysis-mobile-controls { position: sticky; top: -12px; z-index: 5; background: var(--app-bg, #f4f7fb); padding: 0 0 6px; }
|
||||
.analysis-mobile-dimension-heading { display: flex; align-items: center; gap: 8px; color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||
.analysis-mobile-dimension-heading > span { color: var(--color-slate-500, #64748b); font-size: 11px; }
|
||||
.analysis-mobile-dimension-heading > button { margin-left: auto; min-height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; }
|
||||
.analysis-mobile-toolbar { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
.analysis-mobile-toolbar > button { align-self: start; height: 44px; padding: 0 8px; gap: 4px; white-space: nowrap; color: var(--color-blue-600, #2563eb); border-color: var(--color-blue-100, #dbeafe); background: var(--color-blue-50, #eff6ff); }
|
||||
.analysis-dimension-strip { display: flex; gap: 6px; min-width: 0; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; scrollbar-color: var(--color-slate-300, #cbd5e1) transparent; padding-bottom: 4px; }
|
||||
.analysis-dimension-strip button { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 11px; border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 8px; background: #fff; color: var(--color-slate-600, #475569); font-size: 12px; }
|
||||
.analysis-dimension-strip button[aria-pressed=true] { background: var(--color-blue-600, #2563eb); border-color: var(--color-blue-600, #2563eb); color: #fff; box-shadow: 0 2px 4px rgb(37 99 235 / 12%); }
|
||||
.analysis-dimension-strip small { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; background: #ffffff2b; font-size: 10px; }
|
||||
.analysis-mobile-grid { border: 1px solid var(--color-slate-200, #e2e8f0); border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 2px 6px rgb(15 23 42 / 3%); }
|
||||
.analysis-mobile-grid-heading { min-height: 44px; display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 0 12px; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-700, #334155); font-size: 12px; }
|
||||
.analysis-mobile-grid-heading > span { font-size: 11px; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-mobile-grid-heading label { display: inline-flex; align-items: center; gap: 7px; min-height: 44px; font-size: 11px; cursor: pointer; }
|
||||
.analysis-mobile-grid-heading input { appearance: none; width: 30px; height: 18px; border-radius: 10px; background: var(--color-slate-400, #94a3b8); position: relative; cursor: pointer; }
|
||||
.analysis-mobile-grid-heading input::after { content: ''; position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; }
|
||||
.analysis-mobile-grid-heading input:checked { background: var(--color-blue-600, #2563eb); }
|
||||
.analysis-mobile-grid-heading input:checked::after { left: 15px; }
|
||||
.analysis-mobile-grid-hint { padding: 6px 12px; font-size: 11px; color: var(--color-slate-500, #64748b); border-bottom: 1px solid var(--color-slate-200, #e2e8f0); background: var(--color-slate-50, #f8fafc); }
|
||||
.analysis-mobile-grid-scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||||
.analysis-mobile-grid table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; font-size: 12px; }
|
||||
.analysis-mobile-grid th { background: var(--color-slate-50, #f8fafc); color: var(--color-slate-500, #64748b); font-weight: 500; padding: 7px 6px; text-align: left; border-bottom: 1px solid var(--color-slate-200, #e2e8f0); }
|
||||
.analysis-mobile-grid th:first-child { padding-left: 12px; }
|
||||
.analysis-mobile-grid td { padding: 4px 6px; border-bottom: 1px solid var(--color-slate-100, #f1f5f9); vertical-align: middle; overflow-wrap: anywhere; color: var(--color-slate-700, #334155); line-height: 1.5; }
|
||||
.analysis-mobile-grid tr:nth-child(even) td { background: var(--color-slate-50, #f8fafc); }
|
||||
.analysis-mobile-grid tr:last-child td { border-bottom: 0; }
|
||||
.analysis-mobile-grid td:first-child { padding-left: 12px; }
|
||||
.analysis-mobile-grid .mobile-grid-parent { background: var(--color-blue-50, #eff6ff); border-right: 1px solid var(--color-blue-100, #dbeafe); font-weight: 600; vertical-align: top; padding-top: 14px; }
|
||||
.analysis-mobile-grid .mobile-grid-parent small { display: block; margin-top: 6px; font-size: 11px; font-weight: 400; color: var(--color-slate-500, #64748b); }
|
||||
.analysis-mobile-grid .mobile-grid-group-start:not(:first-child) td { border-top: 1px solid var(--color-slate-300, #cbd5e1); }
|
||||
.analysis-mobile-grid .mobile-grid-number { width: 60px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.analysis-mobile-grid th small { display: block; font-size: 10px; }
|
||||
.analysis-mobile-grid td.mobile-grid-number strong { font-size: 17px; color: var(--color-blue-600, #2563eb); font-weight: 600; }
|
||||
.analysis-mobile-grid td.mobile-grid-number small { display: block; font-size: 10px; color: var(--color-amber-600, #d97706); }
|
||||
.analysis-mobile-grid .mobile-grid-share { width: 58px; text-align: right; font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.mobile-grid-share > i { display: block; margin-top: 5px; height: 3px; border-radius: 3px; overflow: hidden; background: var(--color-blue-100, #dbeafe); }
|
||||
.mobile-grid-share > i > i { display: block; height: 100%; background: var(--color-blue-500, #3b82f6); }
|
||||
.analysis-mobile-grid .mobile-grid-actions { width: 90px; text-align: center; padding-left: 0; padding-right: 4px; }
|
||||
.analysis-mobile-grid.without-filter .mobile-grid-actions { width: 56px; }
|
||||
.mobile-grid-actions > div { display: flex; justify-content: flex-end; }
|
||||
.mobile-grid-actions button { display: grid; place-items: center; min-width: 44px; height: 44px; color: var(--color-blue-600, #2563eb); font-size: 11px; border-radius: 7px; }
|
||||
.mobile-grid-actions button:active { background: #dbeafe; }
|
||||
.mobile-grid-actions .mobile-grid-filter { color: var(--color-slate-400, #94a3b8); }
|
||||
.analysis-table-tools { margin-top: 0; }
|
||||
.analysis-mobile-detail-scope { padding: 10px 12px; border-left: 3px solid var(--color-blue-600, #2563eb); border-radius: 0 6px 6px 0; background: var(--color-blue-50, #eff6ff); color: var(--color-slate-600, #475569); }
|
||||
.analysis-vehicle-card { border-color: var(--color-slate-200, #e2e8f0); border-top: 3px solid var(--color-blue-100, #dbeafe); }
|
||||
.bi-flow-modal-summary strong { color: var(--color-blue-600, #2563eb); }
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const parseDate = (value: string) => new Date(`${value}T12:00:00`);
|
||||
const shiftDay = (date: Date, offset: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
|
||||
const dayCount = (start: string, end: string) => Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000) + 1;
|
||||
|
||||
export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onChange: (range: DateRange) => void }) {
|
||||
export function FlowDateRangePicker({ value, onChange, dateLabel = '运维签章时间' }: { value: DateRange; onChange: (range: DateRange) => void; dateLabel?: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [selecting, setSelecting] = useState<'start' | 'end'>('start');
|
||||
@@ -69,7 +69,7 @@ export function FlowDateRangePicker({ value, onChange }: { value: DateRange; onC
|
||||
className="mt-3 flex w-full items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-left shadow-sm transition hover:border-blue-300 hover:bg-blue-50/40 focus-visible:outline-2 focus-visible:outline-blue-500">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-blue-50 text-blue-600"><CalendarDays size={18} /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[10px] font-medium text-slate-400">统计日期 · 运维签章时间</span>
|
||||
<span className="block text-[10px] font-medium text-slate-400">统计日期 · {dateLabel}</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-[13px] font-bold text-slate-700"><span>{value.start.replaceAll('-', '/')}</span><ArrowRight size={13} className="shrink-0 text-slate-300" /><span>{value.end.replaceAll('-', '/')}</span></span>
|
||||
</span>
|
||||
<span className="hidden rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 sm:block">{dayCount(value.start, value.end)} 天</span>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Neutral placeholders communicate structure without suggesting partial statistics. */
|
||||
export function FaultAnalysisLoading() {
|
||||
return <div className="fault-analysis-loading" role="status" aria-live="polite">
|
||||
<div className="fault-loading-status">
|
||||
<svg className="fault-loading-spinner" viewBox="0 0 32 32" aria-hidden="true">
|
||||
{Array.from({ length: 12 }, (_, i) => <rect key={i} x="14.75" y="2" width="2.5" height="7" rx="1.25" fill="currentColor" transform={`rotate(${i * 30} 16 16)`} style={{ animationDelay: `${i / 12 - 1}s` }} />)}
|
||||
</svg>
|
||||
<span>正在加载故障数据…</span>
|
||||
</div>
|
||||
<div className="fault-loading-skeleton" aria-hidden="true">
|
||||
<div className="fault-loading-controls"><span className="fault-loading-block fault-loading-title" /><div>{Array.from({ length: 6 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div></div>
|
||||
<div className="fault-loading-summary"><span className="fault-loading-block" /><span className="fault-loading-block" /></div>
|
||||
<div className="fault-loading-table"><div className="fault-loading-table-head">{Array.from({ length: 3 }, (_, i) => <span className="fault-loading-block" key={i} />)}</div>{Array.from({ length: 5 }, (_, i) => <div className="fault-loading-row" key={i}><span className="fault-loading-block" /><span className="fault-loading-block" /><span className="fault-loading-block" /></div>)}</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Search, Wrench, X } from 'lucide-react';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_OVERVIEW_METRICS, type FaultAnalysisResponse, type FaultOverviewMetric } from '../../../shared/assets/faults';
|
||||
import { getDimensionValueLabel } from '../analytics-model';
|
||||
import { FAULT_CLOSURE_LABELS, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS as DIMENSION_LABELS, analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, type FaultAnalysisDimension, type FaultAnalysisFilters, type FaultAnalysisGroup } from './analysis-model';
|
||||
import { dimensionRowSpans, orderDimensionGroups } from '../dimension-layout';
|
||||
import { FaultAnalysisLoading } from './FaultAnalysisLoading';
|
||||
import { DimensionPicker } from '../components/analysis/DimensionPicker';
|
||||
import { MobileAnalysisControls } from '../components/analysis/MobileAnalysisControls';
|
||||
import { MobileGroupedTable } from '../components/analysis/MobileGroupedTable';
|
||||
import { useDialogFocus } from '../components/useDialogFocus';
|
||||
import { useMobileLayout } from '../useMobileLayout';
|
||||
import '../components/analysis/asset-analysis.css';
|
||||
import '../components/analysis/mobile-analysis.css';
|
||||
|
||||
const available = FAULT_ANALYSIS_DIMENSIONS;
|
||||
// Page-lifetime memory only: closing a modal retains settings; a full reload clears them.
|
||||
const analysisSettings = new Map<string, { dimensions: FaultAnalysisDimension[]; filters: FaultAnalysisFilters }>();
|
||||
export interface FaultAnalysisSource { url: string; title: string; periodLabel: string; definition?: string; defaultDimensions?: FaultAnalysisDimension[] }
|
||||
export function FaultAnalysisModal({ metric, onClose, source }: { metric: FaultOverviewMetric; onClose: () => void; source?: FaultAnalysisSource }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useDialogFocus(true, ref, onClose);
|
||||
const mobile = useMobileLayout();
|
||||
const [data, setData] = useState<FaultAnalysisResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
const defaultDimensions: FaultAnalysisDimension[] = source?.defaultDimensions || ['type'];
|
||||
const settingsKey = source?.url || `overview:${metric}`;
|
||||
const [dimensions, setDimensions] = useState<FaultAnalysisDimension[]>(() => analysisSettings.get(settingsKey)?.dimensions || defaultDimensions);
|
||||
const [filters, setFilters] = useState<FaultAnalysisFilters>(() => analysisSettings.get(settingsKey)?.filters || {});
|
||||
const [merged, setMerged] = useState(true);
|
||||
const [sort, setSort] = useState<'count-desc' | 'count-asc' | 'label'>('count-desc');
|
||||
const [group, setGroup] = useState<FaultAnalysisGroup | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [limit, setLimit] = useState(50);
|
||||
useEffect(() => {
|
||||
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 : '加载失败'); });
|
||||
return () => controller.abort();
|
||||
}, [metric, revision, source?.url]);
|
||||
const records = useMemo(() => data?.rows || [], [data]);
|
||||
const handlerLabels = useMemo(() => new Map(records.flatMap(row => row.handlers.map(h => [h.value, h.label] as const))), [records]);
|
||||
const label = (d: FaultAnalysisDimension, value: string) => d === 'plate' ? value : d === 'faultStatus' ? FAULT_CLOSURE_LABELS[value] || value : d === 'handler' ? handlerLabels.get(value) || '未指派' : d === 'customer' ? data?.customerLabels[value] || '未关联客户' : getDimensionValueLabel(d, value);
|
||||
const options = available.map(key => {
|
||||
const otherFilters = { ...filters }; delete otherFilters[key];
|
||||
const counts = new Map<string, number>();
|
||||
filterFaultRecords(records, otherFilters).forEach(row => faultDimensionValues(row, key).forEach(value => counts.set(value, (counts.get(value) || 0) + 1)));
|
||||
// Keep applied selections removable even if another filter excludes them.
|
||||
filters[key]?.forEach(value => { if (!counts.has(value)) counts.set(value, 0); });
|
||||
return { key, label: DIMENSION_LABELS[key], options: [...counts].map(([value, count]) => ({ value, count, label: label(key, value) })).sort((a, b) => a.label.localeCompare(b.label, 'zh-CN')) };
|
||||
});
|
||||
const analysis = useMemo(() => analyzeFaultRecords(records, dimensions, filters), [records, dimensions, filters]);
|
||||
const values = (row: FaultAnalysisGroup) => dimensions.map(d => row.values[d]!);
|
||||
const ordered = orderDimensionGroups(analysis.rows, dimensions.length, values, row => row.count, sort,
|
||||
(a, b, i) => label(dimensions[i], values(a)[i]).localeCompare(label(dimensions[i], values(b)[i]), 'zh-CN'));
|
||||
const rows = ordered.slice(0, limit);
|
||||
const spans = dimensionRowSpans(rows, dimensions.length, values);
|
||||
const totals = faultParentTotals(ordered, dimensions);
|
||||
const groupLabel = (row: FaultAnalysisGroup) => dimensions.map(d => label(d, row.values[d]!)).join(' · ') || '合计';
|
||||
const apply = (dims: FaultAnalysisDimension[], nextFilters: FaultAnalysisFilters) => {
|
||||
const saved = { dimensions: [...dims], filters: Object.fromEntries(Object.entries(nextFilters).filter(([, values]) => values?.length).map(([key, values]) => [key, [...values!]])) };
|
||||
analysisSettings.set(settingsKey, saved);
|
||||
setDimensions(saved.dimensions); setFilters(saved.filters); setLimit(50);
|
||||
};
|
||||
const reset = () => { analysisSettings.delete(settingsKey); setDimensions([...defaultDimensions]); setFilters({}); setLimit(50); };
|
||||
const showDetails = (row: FaultAnalysisGroup) => { setGroup(row); setSearch(''); setLimit(50); };
|
||||
const filterGroup = (row: FaultAnalysisGroup) => apply(dimensions, { ...filters, ...Object.fromEntries(dimensions.map(d => [d, [row.values[d]!]])) });
|
||||
const groupIds = useMemo(() => new Set(group?.recordIds || []), [group]);
|
||||
const details = group ? records.filter(row => groupIds.has(row.id) &&
|
||||
`${row.code} ${row.plate} ${row.description} ${row.status} ${row.handlers.map(h => h.label).join(' ')}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()))
|
||||
.sort((a, b) => (b.reportedAt || '').localeCompare(a.reportedAt || '') || b.id.localeCompare(a.id, undefined, { numeric: true })) : [];
|
||||
return <div className="asset-analysis-overlay fault-analysis-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="asset-analysis-dialog fault-analysis-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby="fault-analysis-title" tabIndex={-1}>
|
||||
<header className="analysis-header"><div><h2 id="fault-analysis-title"><Wrench size={18} />{source?.title || FAULT_OVERVIEW_METRICS[metric]} · {group ? '故障明细' : '多维分析'}</h2><p>{source?.periodLabel || `${data?.date || '正在读取数据'} · ${metric !== 'reported' && metric !== 'closed' ? '全部日期' : '北京时间自然日'}`} · 按故障单统计</p></div><button onClick={onClose} aria-label="关闭故障分析"><X size={20} /></button></header>
|
||||
<div className="analysis-scroll">
|
||||
{error ? <div className="fault-message" role="alert">{error}<button onClick={() => setRevision(v => v + 1)}>重试</button></div> : !data ? <FaultAnalysisLoading /> : group ? <>
|
||||
<div className="analysis-table-tools"><button className="analysis-button" onClick={() => { setGroup(null); setLimit(50); }}><ArrowLeft size={14} />返回分组</button><span>{groupLabel(group)} · {details.length.toLocaleString()} 单</span><label className="fault-detail-search"><Search size={14} /><input aria-label="搜索故障明细" placeholder="搜索单号、车牌、描述、处置人" value={search} onChange={e => { setSearch(e.target.value); setLimit(50); }} /></label></div>
|
||||
<div className="analysis-group-table is-merged fault-detail-table"><table><thead><tr>{['车牌 / 故障单号', '运维处置人', '故障描述', '状态', '提报时间', '解决时间', '运营城市', '客户 / 部门'].map(h => <th key={h}>{h}</th>)}</tr></thead><tbody>{details.slice(0, limit).map(row => <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.description || '—'}</td><td>{row.status}</td><td>{row.reportedAt || '—'}</td><td>{row.archivedAt || '—'}</td><td>{row.city}</td><td>{row.customerLabel}<small>{row.department}</small></td></tr>)}</tbody></table></div>
|
||||
{!details.length && <p className="fault-empty">没有符合条件的故障明细</p>}
|
||||
{details.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多明细(已显示 {limit} / {details.length})</button>}
|
||||
</> : <>
|
||||
{mobile ? <MobileAnalysisControls available={options} dimensions={dimensions} filters={filters} defaults={defaultDimensions} apply={(dims, next) => apply(dims as FaultAnalysisDimension[], next)} /> : <div className="analysis-builder"><div className="analysis-builder-heading"><h3>分析维度</h3><span>点击维度分组,展开箭头可多选筛选</span><button className="analysis-reset" onClick={reset}>重置</button></div><div className="analysis-dimension-options">{options.map(d => <DimensionPicker key={d.key} label={d.label} grouped={dimensions.includes(d.key)} order={dimensions.indexOf(d.key) + 1} options={d.options} selectedValues={filters[d.key]} onToggleGroup={() => apply(dimensions.includes(d.key) ? dimensions.filter(v => v !== d.key) : [...dimensions, d.key], filters)} onApplyFilter={selected => apply(dimensions, { ...filters, [d.key]: selected })} />)}<button aria-pressed={!dimensions.length} onClick={() => apply([], filters)}>仅汇总</button></div></div>}
|
||||
<div className="analysis-result-bar"><span>当前结果<strong>{analysis.total.toLocaleString()}</strong>单</span><span>{analysis.rows.length.toLocaleString()} 个分组</span>{mobile && <button className="analysis-text-button" onClick={reset}>重置</button>}<button className="analysis-text-button" onClick={() => showDetails({ key: 'all', values: {}, count: analysis.total, share: 100, recordIds: filterFaultRecords(records, filters).map(row => row.id) })} disabled={dimensions.length > 0 || !analysis.total} hidden={dimensions.length > 0}>查看全部明细</button></div>
|
||||
{dimensions.includes('handler') && <p className="fault-analysis-note">运维处置人来自故障或关联工单的当前处置分配。多人共同处置分别计入各人,总数和上级小计按故障去重,人员行不可直接相加。</p>}
|
||||
<div className="analysis-table-tools">{!mobile && <h3>分组统计</h3>}<label>排序<select aria-label="分组排序" value={sort} onChange={e => { setSort(e.target.value as typeof sort); setLimit(50); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">名称顺序</option></select></label>{!mobile && dimensions.length > 1 && <label><input type="checkbox" checked={merged} onChange={e => setMerged(e.target.checked)} />合并单元格</label>}</div>
|
||||
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} rows={rows.map(row => ({ ...row, label: groupLabel(row), values: dimensions.map(d => label(d, row.values[d]!)) }))} unit="单" merged={merged} setMerged={setMerged} spans={spans} parentTotals={totals} onDetails={i => showDetails(rows[i])} onFilter={i => filterGroup(rows[i])} /> : <div className="analysis-group-table"><table><thead><tr>{dimensions.length ? dimensions.map(d => <th key={d}>{DIMENSION_LABELS[d]}</th>) : <th>范围</th>}<th>数量(单)</th><th>占比</th><th>操作</th></tr></thead><tbody>{rows.map((row, ri) => <tr key={row.key}>{dimensions.length ? dimensions.map((d, ci) => {
|
||||
const span = merged ? spans[ri][ci] : 1;
|
||||
return span ? <td key={d} rowSpan={span}>{label(d, row.values[d]!)}{merged && ci < dimensions.length - 1 && <small className="fault-parent-total">合计 {totals[ri][ci].toLocaleString()} 单</small>}</td> : null;
|
||||
}) : <td>合计</td>}<td><strong>{row.count.toLocaleString()}</strong></td><td>{row.share.toFixed(1)}%</td><td>{dimensions.length > 0 && <button className="analysis-text-button" onClick={() => filterGroup(row)}>筛选此组</button>}<button className="analysis-text-button" onClick={() => showDetails(row)} aria-label={`查看明细 ${groupLabel(row)}`}>查看明细</button></td></tr>)}</tbody></table></div>}
|
||||
{!rows.length && <p className="fault-empty">暂无符合条件的故障,可调整筛选条件。</p>}
|
||||
{ordered.length > limit && <button className="analysis-button fault-more" onClick={() => setLimit(v => v + 50)}>加载更多分组(已显示 {limit} / {ordered.length})</button>}
|
||||
<p className="fault-analysis-note">{metric === 'overdue' && '逾期仅统计待处理、处理中且处置截止时间早于本次数据时间的故障;挂起停表,无截止时间不计入。'}{source ? source.definition || '按提报日期筛选同批故障,闭环状态截至本次数据时间;已归档为已闭环,其余为未闭环。' : '维度按车辆当前信息归属;本日解决按解决时间统计。'}下钻读取最新台账,实时变动可能与首页数量存在差异。</p>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { ArrowUpRight, CheckCircle2, ClipboardList, Clock3, PlusCircle, RefreshCw } from 'lucide-react';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_OVERVIEW_METRICS, type FaultOverview, type FaultOverviewMetric } from '../../../shared/assets/faults';
|
||||
import { FaultAnalysisModal } from './FaultAnalysisModal';
|
||||
import { FaultWeeklyReport } from './FaultWeeklyReport';
|
||||
import { FaultTrendCard } from './FaultTrendCard';
|
||||
import './fault-management.css';
|
||||
|
||||
export const FAULT_DEFINITIONS = {
|
||||
reported: '按今日提报时间统计', closed: '按今日解决时间统计',
|
||||
open: '当前在办 = 待处理 + 处理中', ledger: '全部有效故障台账',
|
||||
};
|
||||
const icons = { reported: PlusCircle, closed: CheckCircle2, open: Clock3, ledger: ClipboardList };
|
||||
const metrics = ['reported', 'closed', 'open', 'ledger'] as const;
|
||||
export default function FaultManagement() {
|
||||
const [data, setData] = useState<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>
|
||||
</section>
|
||||
<FaultTrendCard refresh={revision} />
|
||||
<FaultWeeklyReport refresh={revision} />
|
||||
{metric && <FaultAnalysisModal key={metric} metric={metric} onClose={close} />}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { CalendarDays, ChevronDown, RefreshCw } from 'lucide-react';
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import type { FaultTrendResponse, FaultTrendStatus } from '../../../shared/assets/faults';
|
||||
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||
import { downloadFaultReport } from './report-export';
|
||||
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
|
||||
|
||||
function initialRange() {
|
||||
const end = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
return { start: new Date(Date.parse(end) - 29 * 86400000).toISOString().slice(0, 10), end };
|
||||
}
|
||||
const statusLabels = { all: '新增故障', closed: '其中已闭环', unclosed: '其中未闭环' };
|
||||
const countKeys = { all: 'reported', closed: 'closed', unclosed: 'unclosed' } as const;
|
||||
export function FaultTrendCard({ refresh }: { refresh: number }) {
|
||||
const [range, setRange] = useState(initialRange);
|
||||
const [granularity, setGranularity] = useState<'day' | 'month'>('day');
|
||||
const [data, setData] = useState<FaultTrendResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setSource(null), []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setError(''); setData(null);
|
||||
const params = new URLSearchParams({ ...range, granularity });
|
||||
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]);
|
||||
const drill = (status: FaultTrendStatus, period?: string) => {
|
||||
if (!data || loading) return;
|
||||
const params = new URLSearchParams({ start: data.start, end: data.end, granularity: data.granularity, status });
|
||||
if (period) params.set('period', period);
|
||||
setSource({ url: `/api/asset-faults/trend-analysis?${params}`, title: `故障处理趋势 · ${statusLabels[status]}`, defaultDimensions: ['handler', 'faultStatus'],
|
||||
periodLabel: `${data.start} 至 ${data.end}${period ? ` · ${period}` : ''} · 按提报日期` });
|
||||
};
|
||||
return <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>
|
||||
<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> : <>
|
||||
<div className="fault-trend-totals">{(['all', 'closed', 'unclosed'] as const).map(status => <button key={status} className={`fault-trend-${status}`} disabled={!data.totals[countKeys[status]]} onClick={() => drill(status)} aria-label={`${statusLabels[status]} ${data.totals[countKeys[status]]} 单,查看趋势分析`}><strong>{data.totals[countKeys[status]].toLocaleString()}</strong><span>{statusLabels[status]} ↗</span></button>)}</div>
|
||||
{data.totals.reported ? <div ref={chartRef} className="fault-trend-chart" role="region" aria-label={`故障${granularity === 'day' ? '日' : '月'}趋势堆叠柱图,新增${data.totals.reported}单,已闭环${data.totals.closed}单,未闭环${data.totals.unclosed}单;精确数量可展开统计表查看`}>
|
||||
<ResponsiveContainer width="100%" height={240} minWidth={0}>
|
||||
<BarChart data={data.periods} margin={{ top: 12, right: 8, left: -18, bottom: 0 }} accessibilityLayer>
|
||||
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => data.granularity === 'day' ? v.slice(5) : v} minTickGap={24} 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]} />
|
||||
<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} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div> : <p className="fault-empty">所选时间范围暂无新增故障</p>}
|
||||
<button className="fault-trend-expand" aria-expanded={expanded} aria-controls="fault-trend-periods" onClick={() => setExpanded(v => !v)}><span>{expanded ? '收起' : '展开'}{data.granularity === 'day' ? '每日' : '每月'}统计 · 点击数量查看故障闭环明细</span><ChevronDown size={16} style={{ transform: expanded ? 'rotate(180deg)' : undefined }} /></button>
|
||||
{expanded && <div id="fault-trend-periods" className="fault-trend-table"><table><thead><tr><th>{data.granularity === 'day' ? '提报日期' : '提报月份'}</th><th>新增</th><th>已闭环</th><th>未闭环</th><th>闭环率</th></tr></thead><tbody>{[...data.periods].reverse().map(row => <tr key={row.period}><td>{row.period}</td>{(['all', 'closed', 'unclosed'] as const).map(status => <td key={status}><button disabled={!row[countKeys[status]]} onClick={() => drill(status, row.period)} aria-label={`${row.period} ${statusLabels[status]} ${row[countKeys[status]]} 单,查看明细`}>{row[countKeys[status]].toLocaleString()}</button></td>)}<td>{row.reported ? `${(row.closed / row.reported * 100).toFixed(1)}%` : '—'}</td></tr>)}</tbody></table></div>}
|
||||
<p className="fault-summary-note">已归档视为已闭环,其余状态(含挂起)为未闭环;无提报时间不纳入趋势。按月仍仅统计所选日期范围,不扩展至整月。状态截至 {new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
|
||||
</>}
|
||||
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ClipboardList, RefreshCw } from 'lucide-react';
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { fetchJson } from '../../../auth/api-client';
|
||||
import { FAULT_WEEKLY_METRICS, type FaultWeeklyMetric, type FaultWeeklyResponse } from '../../../shared/assets/faults';
|
||||
import { AnalysisExportButton } from '../components/analysis/AnalysisExportButton';
|
||||
import { downloadFaultReport } from './report-export';
|
||||
import { FlowDateRangePicker } from '../components/overview/FlowDateRangePicker';
|
||||
import { getWeeklyFlowRange } from '../model';
|
||||
import { FaultAnalysisModal, type FaultAnalysisSource } from './FaultAnalysisModal';
|
||||
|
||||
const metrics: FaultWeeklyMetric[] = ['reported', 'closed'];
|
||||
const definition = '新增故障按提报时间统计,解决故障按解决时间统计;同一故障可在不同日期分别计入两个指标。';
|
||||
export function FaultWeeklyReport({ refresh }: { refresh: number }) {
|
||||
const [range, setRange] = useState(() => getWeeklyFlowRange());
|
||||
const [data, setData] = useState<FaultWeeklyResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [source, setSource] = useState<FaultAnalysisSource | null>(null);
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setSource(null), []);
|
||||
const today = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true); setData(null); setError('');
|
||||
fetchJson<FaultWeeklyResponse>(`/api/asset-faults/weekly-report?${new URLSearchParams({ start: range.start, end: range.end })}`, { signal: controller.signal })
|
||||
.then(result => { if (!controller.signal.aborted) setData(result); })
|
||||
.catch(e => { if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '加载失败'); })
|
||||
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
||||
return () => controller.abort();
|
||||
}, [range.start, range.end, revision, refresh]);
|
||||
const drill = (metric: FaultWeeklyMetric, day?: string) => {
|
||||
if (!data || loading) return;
|
||||
const params = new URLSearchParams({ start: data.start, end: data.end, metric });
|
||||
if (day) params.set('day', day);
|
||||
setSource({ url: `/api/asset-faults/weekly-report-analysis?${params}`, title: `故障运维周报 · ${FAULT_WEEKLY_METRICS[metric]}`,
|
||||
periodLabel: `${day || `${data.start} 至 ${data.end}`} · 按事件发生时间`, definition });
|
||||
};
|
||||
return <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>
|
||||
<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>
|
||||
<div ref={chartRef} className="fault-trend-chart fault-weekly-chart" role="region" aria-label="每日新增故障与解决故障折线图,精确数量及下钻见下方每日统计表">
|
||||
<div className="fault-chart-heading"><span>每日变化<small>单位:单</small></span><div className="fault-chart-legend" aria-label="图例"><span className="fault-chart-key-reported"><i />新增故障</span><span className="fault-chart-key-closed"><i />解决故障</span></div></div>
|
||||
<ResponsiveContainer width="100%" height={240} minWidth={0}>
|
||||
<LineChart data={data.days} margin={{ top: 20, right: 14, left: 0, bottom: 8 }} accessibilityLayer>
|
||||
<CartesianGrid strokeDasharray="4 6" vertical={false} stroke="#e8edf3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#64748b' }} tickFormatter={v => v.slice(5)} minTickGap={32} tickMargin={12} padding={{ left: 12, right: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis width={30} tickMargin={8} tickCount={5} domain={[0, 'auto']} allowDecimals={false} tick={{ fontSize: 10, fill: '#64748b' }} axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={{ stroke: '#cbd5e1', strokeDasharray: '4 4' }} contentStyle={{ border: '1px solid #e2e8f0', borderRadius: 12, boxShadow: '0 8px 24px rgb(15 23 42 / .08)', padding: '10px 14px', fontSize: 12 }} labelStyle={{ color: '#334155', fontWeight: 600, marginBottom: 6 }} formatter={(value, name) => [`${value} 单`, name]} />
|
||||
<Line type="monotone" dataKey="reported" name="新增故障" stroke="#2563eb" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#fff', strokeWidth: 2 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="closed" name="解决故障" stroke="#059669" strokeWidth={2.5} dot={data.days.length <= 14 ? { r: 3.5, fill: '#059669', stroke: '#fff', strokeWidth: 1.5 } : false} activeDot={{ r: 6, stroke: '#fff', strokeWidth: 3 }} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="fault-trend-table fault-weekly-table" role="region" aria-label="故障运维每日统计表" tabIndex={0}><table><thead><tr><th>日期</th>{metrics.map(metric => <th key={metric}>{FAULT_WEEKLY_METRICS[metric]}</th>)}</tr></thead><tbody>{[...data.days].reverse().map(row => <tr key={row.date} className={row.date === today ? 'fault-weekly-today' : undefined} aria-current={row.date === today ? 'date' : undefined}><td><span className="fault-weekly-date">{row.date}{row.date === today && <span className="fault-today-label">今天</span>}</span><small>{new Date(`${row.date}T12:00:00+08:00`).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'Asia/Shanghai' })}</small></td>{metrics.map(metric => <td key={metric}><button className={`fault-weekly-${metric}`} disabled={!row[metric]} onClick={() => drill(metric, row.date)} aria-label={`${row.date} ${FAULT_WEEKLY_METRICS[metric]} ${row[metric]} 单,查看明细`}>{row[metric].toLocaleString()}</button></td>)}</tr>)}</tbody><tfoot><tr><th>合计</th>{metrics.map(metric => <th key={metric}><button disabled={!data.totals[metric]} onClick={() => drill(metric)} aria-label={`合计${FAULT_WEEKLY_METRICS[metric]} ${data.totals[metric]} 单,查看明细`}>{data.totals[metric].toLocaleString()}</button></th>)}</tr></tfoot></table></div>
|
||||
{!metrics.some(metric => data.totals[metric]) && <p className="fault-weekly-empty">所选日期范围暂无新增或解决故障。</p>}
|
||||
<p className="fault-summary-note">新增故障按提报日期,解决故障按解决日期统计,不受提报日期限制。数据时间:{new Date(data.updatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}。</p>
|
||||
</>}
|
||||
{source && <FaultAnalysisModal key={source.url} metric="ledger" source={source} onClose={close} />}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mapFault, faultHandlers } from '../../../server/routes/asset-faults/model.js';
|
||||
import type { FaultAnalysisRecord, FaultOption } from '../../../shared/assets/faults';
|
||||
import { analyzeFaultRecords, filterFaultRecords, faultDimensionValues, faultParentTotals, FAULT_ANALYSIS_DIMENSIONS, FAULT_ANALYSIS_LABELS } from './analysis-model.js';
|
||||
function fault(id: string, handlers: FaultOption[], manager = '客户业务经理', city = '上海市'): FaultAnalysisRecord {
|
||||
const row = mapFault({ id, business_manager: manager, city_name: city }, new Map());
|
||||
return { ...row, handlers, cell: row.assetCell };
|
||||
}
|
||||
const a = { value: 'id:1234567890123456789', label: '处置甲' };
|
||||
const b = { value: 'id:2234567890123456789', label: '处置乙' };
|
||||
const data = [fault('shared', [a, b, a]), fault('single', [a]), fault('none', [])];
|
||||
|
||||
test('运维处置人是独立维度,未分配不以业务负责人兜底', () => {
|
||||
assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('handler')); assert.ok(FAULT_ANALYSIS_DIMENSIONS.includes('manager'));
|
||||
assert.equal(FAULT_ANALYSIS_LABELS.handler, '运维处置人');
|
||||
assert.deepEqual(faultDimensionValues(data[0], 'handler'), [a.value, b.value]);
|
||||
assert.deepEqual(faultDimensionValues(data[0], 'manager'), ['客户业务经理']);
|
||||
assert.deepEqual(faultDimensionValues(data[2], 'handler'), ['unassigned']);
|
||||
assert.deepEqual(faultHandlers({ business_manager: '客户业务经理' }, new Map()), [{ value: 'unassigned', label: '未指派' }]);
|
||||
});
|
||||
test('多人各自成组,总数和组内明细按故障ID去重,父级小计取并集', () => {
|
||||
const result = analyzeFaultRecords([...data, data[0]], ['city', 'handler']);
|
||||
assert.equal(result.total, 3);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value)?.count, 2);
|
||||
assert.equal(result.rows.find(row => row.values.handler === b.value)?.count, 1);
|
||||
assert.equal(result.rows.reduce((n, row) => n + row.count, 0), 4);
|
||||
assert.ok(faultParentTotals(result.rows, ['city', 'handler']).every(row => row[0] === 3));
|
||||
for (const group of result.rows) assert.equal(new Set(group.recordIds).size, group.count);
|
||||
});
|
||||
test('多选处置人取并集,不分组时不放大;筛选甲后不出现共享故障的乙分组', () => {
|
||||
assert.equal(filterFaultRecords(data, { handler: [a.value, b.value] }).length, 2);
|
||||
assert.equal(analyzeFaultRecords(data, ['city'], { handler: [a.value, b.value] }).rows[0].count, 2);
|
||||
const result = analyzeFaultRecords(data, ['handler'], { handler: [a.value] });
|
||||
assert.equal(result.total, 2); assert.equal(result.rows.length, 1); assert.equal(result.rows[0].values.handler, a.value);
|
||||
assert.equal(filterFaultRecords(data, { handler: [a.value], manager: ['其他业务经理'] }).length, 0);
|
||||
assert.equal(filterFaultRecords(data, { handler: ['unassigned'] })[0].id, 'none');
|
||||
});
|
||||
test('同名处置人保持ID独立,组合筛选与组明细对应,空数据安全', () => {
|
||||
const rows = [fault('one', [{ ...a, label: '同名' }]), fault('two', [{ ...b, label: '同名' }])];
|
||||
const result = analyzeFaultRecords(rows, ['manager', 'handler']);
|
||||
assert.equal(result.rows.length, 2);
|
||||
for (const group of result.rows) {
|
||||
const matched = filterFaultRecords(rows, { manager: [group.values.manager!], handler: [group.values.handler!] });
|
||||
assert.deepEqual(matched.map(row => row.id), group.recordIds);
|
||||
}
|
||||
assert.deepEqual(analyzeFaultRecords([], ['handler']), { total: 0, rows: [] });
|
||||
});
|
||||
|
||||
|
||||
test('故障工单状态按归档闭环,与车辆运营状态独立;组合分组、筛选和去重口径一致', () => {
|
||||
const states = ['已归档', '待处理', '处理中', '挂起', '未知状态'];
|
||||
const rows = states.map((status, i) => ({ ...fault(String(i), i === 0 ? [a, b] : [a]), status }));
|
||||
assert.deepEqual(rows.map(row => faultDimensionValues(row, 'faultStatus')), [['closed'], ['unclosed'], ['unclosed'], ['unclosed'], ['unclosed']]);
|
||||
const result = analyzeFaultRecords(rows, ['handler', 'faultStatus']);
|
||||
assert.equal(result.total, 5);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'closed')?.count, 1);
|
||||
assert.equal(result.rows.find(row => row.values.handler === a.value && row.values.faultStatus === 'unclosed')?.count, 4);
|
||||
const parentTotals = faultParentTotals(result.rows, ['handler', 'faultStatus']);
|
||||
result.rows.forEach((row, i) => assert.equal(parentTotals[i][0], row.values.handler === a.value ? 5 : 1));
|
||||
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['closed'], handler: [a.value, b.value] }).map(row => row.id), ['0']);
|
||||
assert.equal(filterFaultRecords(rows, { faultStatus: ['closed', 'unclosed'] }).length, 5);
|
||||
assert.deepEqual(filterFaultRecords(rows, { faultStatus: ['unclosed'], handler: [b.value] }), []);
|
||||
for (const group of result.rows) assert.deepEqual(filterFaultRecords(rows, { handler: [group.values.handler!], faultStatus: [group.values.faultStatus!] }).map(row => row.id), group.recordIds);
|
||||
});
|
||||
|
||||
|
||||
test('车牌位于第三个维度,同车多故障按单计数,支持组合筛选和缺失车牌', () => {
|
||||
assert.equal(FAULT_ANALYSIS_DIMENSIONS[2], 'plate');
|
||||
const rows = [{ ...fault('1', [a]), plate: '沪A12345' }, { ...fault('2', [b]), plate: '沪A12345' }, { ...fault('3', [a]), plate: '粤A12345' }, { ...fault('4', []), plate: '' }];
|
||||
const grouped = analyzeFaultRecords(rows, ['plate']);
|
||||
assert.equal(grouped.total, 4);
|
||||
assert.equal(grouped.rows.find(row => row.values.plate === '沪A12345')?.count, 2);
|
||||
assert.deepEqual(filterFaultRecords(rows, { plate: ['沪A12345'], handler: [b.value] }).map(row => row.id), ['2']);
|
||||
assert.equal(filterFaultRecords(rows, { plate: ['沪A12345', '粤A12345'] }).length, 3);
|
||||
assert.deepEqual(filterFaultRecords(rows, { plate: ['未关联车辆'] }).map(row => row.id), ['4']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AssetAnalysisDimension } from '../../../shared/assets/analytics';
|
||||
import type { FaultAnalysisRecord } from '../../../shared/assets/faults';
|
||||
import { DIMENSION_LABELS, getAvailableDimensions } from '../analytics-model';
|
||||
|
||||
export type FaultAnalysisDimension = AssetAnalysisDimension | 'handler' | 'plate' | 'faultStatus';
|
||||
export type FaultAnalysisFilters = Partial<Record<FaultAnalysisDimension, string[]>>;
|
||||
export const FAULT_ANALYSIS_DIMENSIONS: FaultAnalysisDimension[] = getAvailableDimensions('assets').flatMap(d => d === 'type' ? [d, 'handler', 'plate', 'faultStatus'] : [d]);
|
||||
export const FAULT_ANALYSIS_LABELS = { ...DIMENSION_LABELS, handler: '运维处置人', plate: '车牌', faultStatus: '故障工单状态' };
|
||||
export const FAULT_CLOSURE_LABELS: Record<string, string> = { closed: '已闭环', unclosed: '未闭环' };
|
||||
export interface FaultAnalysisGroup {
|
||||
key: string; values: Partial<Record<FaultAnalysisDimension, string>>;
|
||||
count: number; share: number; recordIds: string[];
|
||||
}
|
||||
export function faultDimensionValues(row: FaultAnalysisRecord, dimension: FaultAnalysisDimension): string[] {
|
||||
if (dimension === 'plate') return [row.plate?.trim() || '未关联车辆'];
|
||||
if (dimension === 'faultStatus') return [row.status === '已归档' ? 'closed' : 'unclosed'];
|
||||
return dimension === 'handler' ? [...new Set(row.handlers.length ? row.handlers.map(h => h.value) : ['unassigned'])] : [row.cell[dimension]];
|
||||
}
|
||||
export function filterFaultRecords(rows: FaultAnalysisRecord[], filters: FaultAnalysisFilters = {}): FaultAnalysisRecord[] {
|
||||
return [...new Map(rows.filter(row => FAULT_ANALYSIS_DIMENSIONS.every(d => !filters[d]?.length || faultDimensionValues(row, d).some(value => filters[d]!.includes(value)))).map(row => [row.id, row])).values()];
|
||||
}
|
||||
/** Expand only the grouped handler dimension; totals and each group remain distinct fault IDs. */
|
||||
export function analyzeFaultRecords(rows: FaultAnalysisRecord[], dimensions: FaultAnalysisDimension[], filters: FaultAnalysisFilters = {}) {
|
||||
const selected = filterFaultRecords(rows, filters);
|
||||
const groups = new Map<string, { values: FaultAnalysisGroup['values']; ids: Set<string> }>();
|
||||
const validDimensions = [...new Set(dimensions.filter(d => FAULT_ANALYSIS_DIMENSIONS.includes(d)))];
|
||||
for (const row of selected) {
|
||||
let combinations: [FaultAnalysisDimension, string][][] = [[]];
|
||||
for (const d of validDimensions) {
|
||||
const values = faultDimensionValues(row, d).filter(value => !filters[d]?.length || filters[d]!.includes(value));
|
||||
combinations = combinations.flatMap(parts => values.map(value => [...parts, [d, value] as [FaultAnalysisDimension, string]]));
|
||||
}
|
||||
for (const entries of combinations) {
|
||||
const key = JSON.stringify(entries);
|
||||
const group = groups.get(key) || { values: Object.fromEntries(entries), ids: new Set<string>() };
|
||||
group.ids.add(row.id); groups.set(key, group);
|
||||
}
|
||||
}
|
||||
return { total: selected.length, rows: [...groups].map(([key, group]): FaultAnalysisGroup => ({ key, values: group.values,
|
||||
count: group.ids.size, share: selected.length ? group.ids.size / selected.length * 100 : 0, recordIds: [...group.ids] })) };
|
||||
}
|
||||
/** Multi-handler child rows overlap: parent subtotals must use the union, never sum children. */
|
||||
export function faultParentTotals(rows: FaultAnalysisGroup[], dimensions: FaultAnalysisDimension[]): number[][] {
|
||||
const totals = new Map<string, Set<string>>();
|
||||
const keys = rows.map(row => dimensions.slice(0, -1).map((_, depth) => JSON.stringify(dimensions.slice(0, depth + 1).map(d => row.values[d]))));
|
||||
rows.forEach((row, i) => keys[i].forEach(key => {
|
||||
const ids = totals.get(key) || new Set<string>(); row.recordIds.forEach(id => ids.add(id)); totals.set(key, ids);
|
||||
}));
|
||||
return keys.map(row => row.map(key => totals.get(key)!.size));
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.fault-page { color:#334155; min-width:0; padding:24px; }
|
||||
.fault-page-header { position:relative; z-index:30; margin:-24px -24px 24px; padding:18px 24px 12px; background:rgb(255 255 255 / .95); border-bottom:1px solid #f1f5f9; text-align:center; }
|
||||
.fault-page-header h1 { font-size:16px; font-weight:600; letter-spacing:.04em; color:#1f2937; }
|
||||
.fault-page-header>div { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:12px; margin-top:9px; font-size:10px; color:#94a3b8; }
|
||||
.fault-page-header button { display:inline-flex; align-items:center; gap:4px; color:#64748b; cursor:pointer; padding:4px; }
|
||||
.fault-summary { background:white; border:1px solid #f1f5f9; border-radius:16px; padding:20px; box-shadow:0 1px 3px rgb(15 23 42 / .02); }
|
||||
.fault-summary-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:20px; }
|
||||
.fault-summary-heading h2 { font-size:14px; font-weight:600; color:#1e293b; }
|
||||
.fault-summary-heading>span { color:#94a3b8; font-size:11px; }
|
||||
.fault-metrics { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; align-items:start; }
|
||||
.fault-metric { text-align:left; padding:0; border:1px solid #e2e8f0; background:#fff; border-radius:12px; transition:border-color .15s,box-shadow .15s; min-width:0; }
|
||||
.fault-metric:hover:not(:disabled) { border-color:#93c5fd; box-shadow:0 4px 12px rgb(37 99 235 / .06); }
|
||||
.fault-metric-main:focus-visible { outline:2px solid #2563eb; outline-offset:3px; }
|
||||
.fault-metric-main:disabled { cursor:default; opacity:.6; }
|
||||
.fault-metric-label { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:500; }
|
||||
.fault-metric-label>svg { margin-left:auto; color:#94a3b8; }
|
||||
.fault-metric-icon { display:flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:9px; background:#eff6ff; color:#2563eb; flex-shrink:0; }
|
||||
.fault-metric-closed .fault-metric-icon { background:#ecfdf5; color:#059669; }
|
||||
.fault-metric-open .fault-metric-icon { background:#fff7ed; color:#d97706; }
|
||||
.fault-metric-ledger .fault-metric-icon { background:#f5f3ff; color:#7c3aed; }
|
||||
.fault-metric-count { display:flex; align-items:baseline; gap:8px; margin:18px 0 10px; }
|
||||
.fault-metric-count strong { font-size:32px; line-height:1.15; font-weight:650; color:#0f172a; font-variant-numeric:tabular-nums; }
|
||||
.fault-metric-count>span { font-size:11px; color:#94a3b8; }
|
||||
.fault-metric p { font-size:11px; color:#94a3b8; line-height:1.6; min-height:35px; }
|
||||
.fault-metric-link { display:block; font-size:11px; color:#3b82f6; margin-top:12px; }
|
||||
.fault-message { margin:12px 0; padding:14px; border:1px solid #fecaca; background:#fef2f2; border-radius:8px; color:#b91c1c; font-size:12px; }
|
||||
.fault-message button { margin-left:12px; text-decoration:underline; cursor:pointer; }
|
||||
.fault-parent-total,.fault-detail-table small { display:block; margin-top:5px; font-size:11px; color:#94a3b8; }
|
||||
.fault-detail-table table { min-width:1100px; }
|
||||
.fault-detail-table td:first-child { white-space:nowrap; }
|
||||
.fault-detail-table td:first-child strong { color:#1e293b; font-weight:600; }
|
||||
.fault-detail-table td:nth-child(2) { min-width:100px; max-width:180px; white-space:normal; overflow-wrap:anywhere; }
|
||||
.fault-detail-table td:nth-child(3) { min-width:180px; max-width:320px; white-space:normal; overflow-wrap:anywhere; }
|
||||
.fault-detail-search { display:flex; gap:6px; border:1px solid #e2e8f0; padding:8px; border-radius:6px; }
|
||||
.fault-detail-search input { min-width:0; width:220px; outline:none; }
|
||||
.fault-empty { text-align:center; padding:32px; color:#94a3b8; }
|
||||
.fault-analysis-note { margin-top:16px; color:#94a3b8; font-size:11px; line-height:1.7; }
|
||||
.fault-analysis-dialog .analysis-dimension-options { align-items:flex-start; }
|
||||
.fault-more { display:flex; margin:16px auto; }
|
||||
@media(max-width:1100px) { .fault-metrics { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media(max-width:767px) {
|
||||
.fault-page { padding:12px; }
|
||||
.fault-page-header { position:relative; z-index:30; margin:-12px -12px 16px; padding:14px 12px 10px; }
|
||||
.fault-page-header h1 { font-size:15px; }
|
||||
.fault-summary { padding:14px; border-radius:12px; }
|
||||
.fault-summary-heading { align-items:flex-start; flex-direction:column; gap:6px; margin-bottom:14px; }
|
||||
.fault-metrics { gap:10px; }
|
||||
.fault-metric { padding:0; border-radius:10px; }
|
||||
.fault-metric-label { font-size:12px; gap:6px; }
|
||||
.fault-metric-icon { width:28px; height:28px; }
|
||||
.fault-metric-label>svg { display:none; }
|
||||
.fault-metric-count strong { font-size:29px; }
|
||||
.fault-metric p { font-size:10px; }
|
||||
.fault-detail-search { width:100%; }
|
||||
.fault-detail-search input { width:100%; }
|
||||
}
|
||||
|
||||
.fault-metric-main { display:block; width:100%; padding:18px; text-align:left; cursor:pointer; border-radius:12px; }
|
||||
/* Fixed compact height keeps every metric aligned, including the open-work breakdown. */
|
||||
.fault-metric { height:164px; display:flex; flex-direction:column; }
|
||||
.fault-metric-main { flex:1; min-height:0; display:flex; flex-direction:column; padding:12px 14px; }
|
||||
.fault-metric-label { width:100%; }
|
||||
.fault-metric-icon { width:28px; height:28px; border-radius:7px; }
|
||||
.fault-metric-count { margin:8px 0 5px; }
|
||||
.fault-metric-count strong { font-size:28px; }
|
||||
.fault-metric p { min-height:0; font-size:10px; }
|
||||
.fault-metric-link { margin-top:auto; padding-top:6px; font-size:10px; }
|
||||
.fault-metric-open { position:relative; }
|
||||
.fault-metric-open .fault-metric-main>p,.fault-metric-open .fault-metric-link { display:none; }
|
||||
.fault-open-breakdown { display:flex; align-items:center; gap:4px; margin:0 12px 12px; padding-top:8px; border-top:1px solid #f1f5f9; }
|
||||
.fault-submetric { flex:1; min-width:0; display:flex; flex-direction:column; align-items:center; gap:2px; padding:4px 2px; border-radius:5px; font-size:10px; cursor:pointer; color:#64748b; }
|
||||
.fault-submetric:hover { background:#eff6ff; color:#2563eb; }
|
||||
.fault-submetric:focus-visible { outline:2px solid #2563eb; outline-offset:1px; }
|
||||
.fault-submetric:disabled { opacity:.6; cursor:default; }
|
||||
.fault-submetric strong { font-variant-numeric:tabular-nums; font-size:16px; line-height:1.2; color:#334155; }
|
||||
.fault-submetric svg { display:none; }
|
||||
.fault-composition-plus { color:#94a3b8; font-size:18px; font-weight:400; }
|
||||
.fault-submetric-waiting { background:#fffbeb; color:#b45309; }
|
||||
.fault-submetric-processing { background:#eff6ff; color:#2563eb; }
|
||||
.fault-overdue-subset { position:absolute; right:14px; top:51px; display:flex; align-items:baseline; gap:4px; padding:5px 8px; border:1px solid #fee2e2; border-radius:6px; background:#fef2f2; color:#dc2626; cursor:pointer; }
|
||||
.fault-overdue-subset span,.fault-overdue-subset small { font-size:10px; }
|
||||
.fault-overdue-subset strong { font-size:16px; line-height:1.2; font-variant-numeric:tabular-nums; }
|
||||
.fault-overdue-subset:hover { background:#fee2e2; }
|
||||
.fault-overdue-subset:focus-visible { outline:2px solid #dc2626; outline-offset:2px; }
|
||||
.fault-overdue-subset:disabled { opacity:.6; cursor:default; }
|
||||
.fault-summary-note { margin-top:12px; font-size:10px; color:#94a3b8; line-height:1.6; }
|
||||
@media(max-width:767px) {
|
||||
.fault-metric { height:172px; }
|
||||
.fault-metric-main { padding:12px; }
|
||||
.fault-open-breakdown { margin:0 8px 10px; gap:2px; }
|
||||
.fault-submetric { min-height:40px; }
|
||||
.fault-overdue-subset { right:8px; top:53px; gap:3px; padding:5px; }
|
||||
.fault-overdue-subset span,.fault-overdue-subset small { font-size:9px; }
|
||||
}
|
||||
.fault-trend { margin-top:20px; }
|
||||
.fault-trend-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.fault-trend-heading h2 { display:flex; align-items:center; gap:7px; font-size:14px; font-weight:600; }
|
||||
.fault-trend-heading h2 svg { color:#2563eb; }
|
||||
.fault-trend-heading [role=group] { display:flex; padding:3px; border-radius:8px; background:#f1f5f9; }
|
||||
.fault-trend-heading button { padding:6px 12px; border-radius:6px; font-size:11px; color:#64748b; cursor:pointer; }
|
||||
.fault-trend-heading button[aria-pressed=true] { color:#2563eb; background:#fff; box-shadow:0 1px 3px #e2e8f0; }
|
||||
.fault-trend-definition { margin-top:7px; font-size:11px; line-height:1.7; color:#94a3b8; }
|
||||
.fault-trend-totals { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); margin:14px 0; padding:10px; border:1px solid #f1f5f9; border-radius:10px; background:#f8fafc; }
|
||||
.fault-trend-totals button { display:flex; flex-direction:column; align-items:center; gap:4px; cursor:pointer; border-right:1px solid #e2e8f0; padding:4px; }
|
||||
.fault-trend-totals button:last-child { border-right:0; }
|
||||
.fault-trend-totals strong { font-size:21px; line-height:1.2; font-weight:650; font-variant-numeric:tabular-nums; }
|
||||
.fault-trend-totals span { font-size:11px; }
|
||||
.fault-trend-all { color:#2563eb; }.fault-trend-closed { color:#059669; }.fault-trend-unclosed { color:#d97706; }
|
||||
.fault-trend button:disabled { cursor:default; opacity:.5; }
|
||||
.fault-trend button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
|
||||
.fault-trend-chart { width:100%; min-width:0; height:240px; }
|
||||
.fault-trend-loading { min-height:120px; display:flex; align-items:center; justify-content:center; gap:8px; color:#94a3b8; font-size:12px; }
|
||||
.fault-trend-expand { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; margin-top:12px; padding:12px; border:1px solid #bfdbfe; border-radius:8px; color:#2563eb; background:#eff6ff; font-size:12px; cursor:pointer; text-align:left; }
|
||||
.fault-trend-table { max-height:400px; overflow:auto; margin-top:12px; border:1px solid #e2e8f0; border-radius:8px; }
|
||||
.fault-trend-table table { width:100%; border-collapse:collapse; font-size:12px; white-space:nowrap; }
|
||||
.fault-trend-table th { text-align:right; padding:10px 12px; position:sticky; top:0; background:#f8fafc; color:#64748b; z-index:1; }
|
||||
.fault-trend-table td { padding:5px 12px; text-align:right; border-top:1px solid #f1f5f9; font-variant-numeric:tabular-nums; }
|
||||
.fault-trend-table th:first-child,.fault-trend-table td:first-child { text-align:left; }
|
||||
.fault-trend-table button { min-height:32px; min-width:32px; color:#2563eb; cursor:pointer; }
|
||||
@media(max-width:767px) { .fault-trend { margin-top:14px; }.fault-trend-heading h2 { font-size:12px; }.fault-trend-heading button { padding:7px 10px; }.fault-trend-table th,.fault-trend-table td { padding-left:6px; padding-right:6px; font-size:10px; }.fault-trend-table button { min-height:40px; }.fault-trend-expand { font-size:11px; } }
|
||||
.fault-weekly .fault-trend-totals { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.fault-weekly-unit { font-size:11px; color:#94a3b8; }
|
||||
.fault-weekly-reported,.fault-weekly-table button.fault-weekly-reported { color:#2563eb; }
|
||||
.fault-weekly-received,.fault-weekly-table button.fault-weekly-received { color:#d97706; }
|
||||
.fault-weekly-closed,.fault-weekly-table button.fault-weekly-closed { color:#059669; }
|
||||
.fault-weekly-table td small { display:inline-block; margin-left:8px; color:#94a3b8; font-size:10px; }
|
||||
.fault-weekly-table tfoot th { position:static; border-top:1px solid #e2e8f0; background:#f8fafc; padding-top:4px; padding-bottom:4px; }
|
||||
.fault-weekly-empty { margin-top:10px; font-size:12px; color:#94a3b8; text-align:center; }
|
||||
@media(max-width:767px) { .fault-weekly-table td small { display:block; margin:0; }.fault-weekly .fault-trend-totals span { font-size:10px; } }
|
||||
|
||||
.fault-weekly-chart { height:auto; padding:16px 12px 4px; border:1px solid #edf2f7; border-radius:12px; background:#fff; }
|
||||
.fault-chart-heading { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; padding:0 4px 4px; font-size:12px; color:#334155; font-weight:600; }
|
||||
.fault-chart-heading small { margin-left:10px; font-size:10px; color:#94a3b8; font-weight:400; }
|
||||
.fault-chart-legend { display:flex; align-items:center; gap:18px; font-size:11px; color:#64748b; font-weight:400; }
|
||||
.fault-chart-legend>span { display:flex; align-items:center; gap:7px; }
|
||||
.fault-chart-legend i { position:relative; width:20px; height:2px; background:#2563eb; }
|
||||
.fault-chart-legend i::after { content:''; position:absolute; left:7px; top:-3px; width:8px; height:8px; border:2px solid #2563eb; border-radius:50%; background:#fff; }
|
||||
.fault-chart-key-closed i { background:#059669; }
|
||||
.fault-chart-key-closed i::after { border-color:#059669; background:#059669; }
|
||||
@media(max-width:767px) { .fault-weekly-chart { padding:14px 6px 2px; }.fault-chart-heading { gap:10px; }.fault-chart-legend { gap:12px; font-size:10px; } }
|
||||
|
||||
/* Compact mobile summary: preserve equal card heights and all drill-down targets. */
|
||||
@media(max-width:767px) {
|
||||
.fault-page-header { margin-bottom:10px; padding:10px 12px 6px; }
|
||||
.fault-page-header>div { gap:8px; margin-top:3px; }
|
||||
.fault-page-header button { min-height:32px; }
|
||||
.fault-page>.fault-summary:first-of-type { padding:12px; }
|
||||
.fault-summary-heading { flex-direction:row; flex-wrap:wrap; align-items:center; gap:4px 8px; margin-bottom:10px; }
|
||||
.fault-summary-heading h2 { font-size:13px; }
|
||||
.fault-summary-heading>span { font-size:10px; }
|
||||
.fault-metrics { gap:8px; }
|
||||
.fault-metric { height:132px; }
|
||||
.fault-metric-main { padding:10px; }
|
||||
.fault-metric-label { gap:6px; }
|
||||
.fault-metric-icon { width:24px; height:24px; border-radius:6px; }
|
||||
.fault-metric-icon svg { width:17px; height:17px; }
|
||||
.fault-metric-count { gap:5px; margin:5px 0 3px; }
|
||||
.fault-metric-count strong { font-size:26px; }
|
||||
.fault-metric-link { padding-top:4px; }
|
||||
.fault-open-breakdown { margin:0 8px 8px; padding-top:6px; gap:3px; }
|
||||
.fault-submetric { flex-direction:row; justify-content:center; gap:4px; min-height:32px; padding:4px 2px; }
|
||||
.fault-submetric strong { font-size:14px; }
|
||||
.fault-composition-plus { font-size:14px; }
|
||||
.fault-overdue-subset { top:41px; right:8px; min-height:32px; padding:4px; gap:3px; align-items:center; }
|
||||
.fault-overdue-subset .fault-overdue-prefix { display:none; }
|
||||
.fault-overdue-subset strong { font-size:14px; }
|
||||
.fault-page>.fault-summary:first-of-type>.fault-summary-note { margin-top:8px; font-size:10px; line-height:1.5; }
|
||||
}
|
||||
@media(max-width:359px) {
|
||||
.fault-open-breakdown { margin-right:6px; margin-left:6px; gap:2px; }
|
||||
.fault-submetric { gap:2px; padding-right:0; padding-left:0; font-size:9px; white-space:nowrap; }
|
||||
.fault-submetric strong { font-size:13px; }
|
||||
.fault-composition-plus { font-size:12px; }
|
||||
}
|
||||
|
||||
.fault-weekly-date { display:inline-flex; align-items:center; gap:6px; }
|
||||
.fault-weekly-table tr.fault-weekly-today { background:#f5f8ff; }
|
||||
.fault-weekly-today td:first-child { box-shadow:inset 2px 0 #bfd0ee; }
|
||||
.fault-today-label { padding:1px 5px; border-radius:4px; background:#e8effb; color:#536d96; font-size:10px; line-height:1.6; font-weight:500; }
|
||||
@media(max-width:767px) { .fault-weekly-date { gap:4px; }.fault-today-label { padding:1px 4px; font-size:9px; } }
|
||||
|
||||
/* Short, single-pass entrance: no bounce, list staggering, or animated blur. */
|
||||
.fault-analysis-overlay { animation:fault-backdrop-enter 160ms ease-out backwards; }
|
||||
.fault-analysis-dialog { --fault-enter-y:12px; --fault-enter-scale:.985; transform-origin:50% 60%; animation:fault-dialog-enter 260ms cubic-bezier(.16,1,.3,1) backwards; }
|
||||
@keyframes fault-backdrop-enter { from { opacity:0; } to { opacity:1; } }
|
||||
@keyframes fault-dialog-enter { from { opacity:0; transform:translateY(var(--fault-enter-y)) scale(var(--fault-enter-scale)); } to { opacity:1; transform:none; } }
|
||||
@media(max-width:767px) { .fault-analysis-dialog { --fault-enter-y:8px; --fault-enter-scale:1; animation-duration:220ms; } }
|
||||
@media(prefers-reduced-motion:reduce) { .fault-analysis-overlay,.fault-analysis-dialog { animation:none; } }
|
||||
|
||||
/* Scoped Apple-style semantic grays; keep the surrounding BI theme unchanged. */
|
||||
.fault-analysis-loading { --loading-label:rgba(60,60,67,.72); --loading-fill:rgba(118,118,128,.09); --loading-line:rgba(60,60,67,.08); --loading-radius:12px; padding:8px 0 4px; min-height:340px; }
|
||||
.fault-loading-status { display:flex; align-items:center; justify-content:center; gap:10px; min-height:40px; margin-bottom:20px; color:var(--loading-label); font-size:12px; letter-spacing:.01em; }
|
||||
.fault-loading-spinner { width:24px; height:24px; flex-shrink:0; }
|
||||
.fault-loading-spinner rect { opacity:.2; animation:fault-spinner-tick 1s linear infinite; }
|
||||
@keyframes fault-spinner-tick { from { opacity:1; } to { opacity:.15; } }
|
||||
.fault-loading-skeleton { animation:fault-skeleton-breathe 1.8s ease-in-out infinite; }
|
||||
@keyframes fault-skeleton-breathe { 0%,100% { opacity:.65; } 50% { opacity:1; } }
|
||||
.fault-loading-block { display:block; height:10px; border-radius:5px; background:var(--loading-fill); }
|
||||
.fault-loading-controls { padding:16px; border:1px solid var(--loading-line); border-radius:var(--loading-radius); }
|
||||
.fault-loading-title { width:84px; margin-bottom:14px; }
|
||||
.fault-loading-controls>div { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.fault-loading-controls>div>span { width:88px; height:28px; border-radius:7px; }
|
||||
.fault-loading-controls>div>span:nth-child(2n) { width:106px; }
|
||||
.fault-loading-summary { display:flex; align-items:center; gap:16px; padding:20px 0; }
|
||||
.fault-loading-summary>span:first-child { width:128px; height:18px; }.fault-loading-summary>span:last-child { width:66px; }
|
||||
.fault-loading-table { border:1px solid var(--loading-line); border-radius:var(--loading-radius); overflow:hidden; }
|
||||
.fault-loading-table-head,.fault-loading-row { display:grid; grid-template-columns:2fr 1fr 1fr; align-items:center; gap:24px; padding:16px; }
|
||||
.fault-loading-table-head { background:var(--loading-fill); }
|
||||
.fault-loading-row { border-top:1px solid var(--loading-line); }
|
||||
.fault-loading-table .fault-loading-block { width:60%; max-width:130px; }
|
||||
.fault-loading-row:nth-child(2n)>.fault-loading-block:first-child { width:80%; }
|
||||
@media(max-width:767px) { .fault-loading-status { margin-bottom:16px; }.fault-loading-controls { padding:12px; }.fault-loading-controls>div { flex-wrap:nowrap; overflow:hidden; }.fault-loading-controls>div>span { flex-shrink:0; width:76px; }.fault-loading-table-head,.fault-loading-row { padding:16px 12px; gap:16px; } }
|
||||
@media(prefers-reduced-motion:reduce) { .fault-loading-spinner rect,.fault-loading-skeleton { animation:none; }.fault-loading-spinner rect:nth-child(3n) { opacity:.65; } }
|
||||
@media(prefers-contrast:more) { .fault-analysis-loading { --loading-label:#334155; --loading-line:#94a3b8; --loading-fill:rgba(118,118,128,.18); } }
|
||||
|
||||
.fault-report-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.fault-report-actions .analysis-export-action { display:flex; flex-direction:column; align-items:flex-end; gap:4px; }
|
||||
.fault-report-actions .analysis-export-action button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:32px; border:1px solid #dbe4f0; border-radius:7px; background:#fff; color:#475569; padding:6px 10px; white-space:nowrap; }
|
||||
.fault-report-actions .analysis-export-action button:hover:not(:disabled) { color:#2563eb; border-color:#93c5fd; background:#eff6ff; }
|
||||
.fault-report-actions .analysis-export-action small { font-size:10px; color:#dc2626; }
|
||||
@media(max-width:767px) { .fault-trend-heading { flex-wrap:wrap; gap:8px; }.fault-report-actions { gap:6px; margin-left:auto; }.fault-report-actions .analysis-export-action button { min-height:40px; padding:7px 8px; font-size:10px; }.fault-report-actions .fault-weekly-unit { display:none; } }
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createFaultReportWorkbook, type FaultReport } from './report-export';
|
||||
|
||||
const now = new Date('2026-10-13T08:00:00Z');
|
||||
async function saved(report: FaultReport) {
|
||||
const book = await createFaultReportWorkbook(report, undefined, now);
|
||||
const reopened = new ExcelJS.Workbook();
|
||||
await reopened.xlsx.load(await book.xlsx.writeBuffer());
|
||||
return reopened.worksheets[0];
|
||||
}
|
||||
|
||||
test('trend export preserves monthly range, real dates, cohort counts and weighted closure rate after serialization', async () => {
|
||||
const sheet = await saved({ kind: 'trend', data: {
|
||||
start: '2026-09-14', end: '2026-10-13', granularity: 'month', updatedAt: now.toISOString(),
|
||||
totals: { reported: 10, closed: 7, unclosed: 3 },
|
||||
periods: [{ period: '2026-09', reported: 8, closed: 5, unclosed: 3 }, { period: '2026-10', reported: 2, closed: 2, unclosed: 0 }],
|
||||
} });
|
||||
assert.match(String(sheet.getCell('A2').value), /2026-09-14 至 2026-10-13.*按月/);
|
||||
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-01T00:00:00.000Z');
|
||||
assert.equal(sheet.getCell('A10').numFmt, 'yyyy-mm');
|
||||
assert.equal(sheet.getCell('B11').value, 8);
|
||||
assert.equal(sheet.getCell('C11').value, 5);
|
||||
assert.equal(sheet.getCell('D11').value, 3);
|
||||
assert.deepEqual(sheet.getCell('B12').value, { formula: 'SUM(B10:B11)', result: 10 });
|
||||
assert.deepEqual(sheet.getCell('E12').value, { formula: 'IFERROR(C12/B12,0)', result: .7 });
|
||||
assert.equal(sheet.autoFilter, 'A9:E11');
|
||||
assert.equal(sheet.views[0].state, 'frozen');
|
||||
assert.equal(sheet.getCell('A9').fill.type, 'pattern');
|
||||
});
|
||||
|
||||
test('weekly export keeps independent solved counts, descending dates and excludes receipt metrics', async () => {
|
||||
const sheet = await saved({ kind: 'weekly', data: {
|
||||
start: '2026-10-12', end: '2026-10-13', updatedAt: now.toISOString(),
|
||||
totals: { reported: 3, closed: 9, received: 99 },
|
||||
days: [{ date: '2026-10-12', reported: 1, closed: 3, received: 90 }, { date: '2026-10-13', reported: 2, closed: 6, received: 9 }],
|
||||
} });
|
||||
assert.deepEqual([1, 2, 3, 4].map(col => sheet.getCell(9, col).value), ['日期', '星期', '新增故障(单)', '解决故障(单)']);
|
||||
assert.equal((sheet.getCell('A10').value as Date).toISOString(), '2026-10-13T00:00:00.000Z');
|
||||
assert.match(sheet.getCell('A10').numFmt, /今天/);
|
||||
assert.equal(sheet.getCell('B10').value, '周二');
|
||||
assert.equal(sheet.getCell('D10').value, 6);
|
||||
assert.deepEqual(sheet.getCell('D12').value, { formula: 'SUM(D10:D11)', result: 9 });
|
||||
assert.deepEqual(sheet.getCell('C6').value, { formula: 'SUM(D10:D11)', result: 9 });
|
||||
assert.equal(sheet.columnCount, 4);
|
||||
});
|
||||
|
||||
test('zero-data trend exports valid guarded ratios and empty reports avoid inverted SUM ranges', async () => {
|
||||
for (const periods of [[], [{ period: '2026-10-13', reported: 0, closed: 0, unclosed: 0 }]]) {
|
||||
const sheet = await saved({ kind: 'trend', data: {
|
||||
start: '2026-10-13', end: '2026-10-13', granularity: 'day', updatedAt: now.toISOString(),
|
||||
totals: { reported: 0, closed: 0, unclosed: 0 }, periods,
|
||||
} });
|
||||
const total = 10 + periods.length;
|
||||
assert.equal(sheet.getCell(`E${total}`).formula, `IFERROR(C${total}/B${total},0)`);
|
||||
assert.equal(sheet.getCell(`B${total}`).formula, periods.length ? 'SUM(B10:B10)' : '0');
|
||||
assert.equal(sheet.getCell('A6').result, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { FaultTrendResponse, FaultWeeklyResponse } from '../../../shared/assets/faults';
|
||||
import type { Cell, Worksheet } from 'exceljs';
|
||||
|
||||
export type FaultReport = { kind: 'trend'; data: FaultTrendResponse } | { kind: 'weekly'; data: FaultWeeklyResponse };
|
||||
interface ReportChartImage { dataUrl: string; width: number; height: number }
|
||||
const palette = { navy: '1E293B', text: '334155', muted: '64748B', blue: '2563EB', green: '059669', amber: 'D97706', line: 'E2E8F0', stripe: 'F8FAFC', today: 'EFF6FF', white: 'FFFFFF' };
|
||||
const fill = (color: string) => ({ type: 'pattern' as const, pattern: 'solid' as const, fgColor: { argb: `FF${color}` } });
|
||||
const excelDate = (date: string) => new Date(`${date.length === 7 ? `${date}-01` : date}T00:00:00Z`);
|
||||
const beijingTime = (date: string | Date) => new Date(date).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
function merged(sheet: Worksheet, row: number, from: number, to: number, value: Cell['value']) {
|
||||
if (to > from) sheet.mergeCells(row, from, row, to);
|
||||
const cell = sheet.getCell(row, from); cell.value = value; return cell;
|
||||
}
|
||||
|
||||
/** Browser-independent workbook builder; export the displayed authorized snapshot without requerying. */
|
||||
export async function createFaultReportWorkbook(report: FaultReport, chartImage?: ReportChartImage, now = new Date()) {
|
||||
const ExcelJS = (await import('exceljs')).default;
|
||||
const book = new ExcelJS.Workbook();
|
||||
book.creator = '羚牛氢能 BI'; book.created = now; book.modified = now;
|
||||
const trend = report.kind === 'trend';
|
||||
const title = trend ? '故障处理趋势' : '故障运维周报';
|
||||
const headers = trend ? [report.data.granularity === 'month' ? '提报月份' : '提报日期', '新增故障(单)', '已闭环(单)', '未闭环(单)', '闭环率'] : ['日期', '星期', '新增故障(单)', '解决故障(单)'];
|
||||
const columns = headers.length;
|
||||
const sheet = book.addWorksheet(title, { properties: { defaultRowHeight: 22 }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, horizontalCentered: true, margins: { left: .3, right: .3, top: .4, bottom: .4, header: .2, footer: .2 } } });
|
||||
sheet.columns = (trend ? [24, 19, 19, 19, 18] : [26, 16, 25, 25]).map(width => ({ width }));
|
||||
const headerRow = 9;
|
||||
const periods = trend ? [...report.data.periods].reverse() : [...report.data.days].reverse();
|
||||
const start = headerRow + 1, end = headerRow + periods.length, totalRow = end + 1;
|
||||
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10);
|
||||
const bottom = totalRow + 3;
|
||||
for (let row = 1; row <= bottom; row++) for (let col = 1; col <= columns; col++) {
|
||||
const cell = sheet.getCell(row, col);
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
||||
}
|
||||
const titleCell = merged(sheet, 1, 1, columns, `羚牛氢能 · ${title}`);
|
||||
titleCell.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
titleCell.fill = fill(palette.navy); titleCell.alignment = { vertical: 'middle', indent: 1 }; sheet.getRow(1).height = 48;
|
||||
merged(sheet, 2, 1, columns, `统计范围:${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
||||
const stamp = merged(sheet, 3, 1, columns, `数据时间:${beijingTime(report.data.updatedAt)}(北京时间)`); stamp.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
sheet.getRow(2).height = 28; sheet.getRow(4).height = 10; sheet.getRow(7).height = 10;
|
||||
const sum = (column: string) => periods.length ? `SUM(${column}${start}:${column}${end})` : '0';
|
||||
const cards = trend
|
||||
? [{ from: 1, to: 2, label: '新增故障', col: 'B', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '已闭环', col: 'C', value: report.data.totals.closed, color: palette.green }, { from: 5, to: 5, label: '未闭环', col: 'D', value: report.data.totals.unclosed, color: palette.amber }]
|
||||
: [{ from: 1, to: 2, label: '新增故障', col: 'C', value: report.data.totals.reported, color: palette.blue }, { from: 3, to: 4, label: '解决故障', col: 'D', value: report.data.totals.closed, color: palette.green }];
|
||||
for (const card of cards) {
|
||||
const label = merged(sheet, 5, card.from, card.to, card.label);
|
||||
const value = merged(sheet, 6, card.from, card.to, { formula: sum(card.col), result: card.value });
|
||||
for (const cell of [label, value]) { cell.fill = fill(palette.stripe); cell.alignment = { horizontal: 'center', vertical: 'middle' }; }
|
||||
label.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.muted}` } };
|
||||
value.font = { name: 'Microsoft YaHei', size: 26, bold: true, color: { argb: `FF${card.color}` } }; value.numFmt = '#,##0';
|
||||
}
|
||||
sheet.getRow(5).height = 25; sheet.getRow(6).height = 40;
|
||||
if (chartImage) {
|
||||
const chart = book.addWorksheet('图表', { views: [{ showGridLines: false }], pageSetup: { paperSize: 9, orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 1 } });
|
||||
chart.columns = headers.map(() => ({ width: 900 / columns / 7 }));
|
||||
chart.properties.defaultRowHeight = 24;
|
||||
const heading = merged(chart, 1, 1, columns, `羚牛氢能 · ${title}`);
|
||||
heading.font = { name: 'Microsoft YaHei', size: 22, bold: true, color: { argb: 'FFFFFFFF' } }; heading.fill = fill(palette.navy); heading.alignment = { vertical: 'middle', indent: 1 }; chart.getRow(1).height = 48;
|
||||
const period = merged(chart, 2, 1, columns, `${report.data.start} 至 ${report.data.end} · ${trend && report.data.granularity === 'month' ? '按月' : '按日'} · 单位:单`);
|
||||
period.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
const key = merged(chart, 3, 1, columns, trend ? '绿色:已闭环 橙色:未闭环 柱总高为新增故障数' : '蓝色:新增故障 绿色:解决故障');
|
||||
key.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
const id = book.addImage({ base64: chartImage.dataUrl, extension: 'png' });
|
||||
const scale = Math.min(900 / chartImage.width, 360 / chartImage.height);
|
||||
const width = chartImage.width * scale, height = chartImage.height * scale;
|
||||
// Keep circles and labels proportional for both desktop and mobile snapshots.
|
||||
chart.addImage(id, { tl: { col: (900 - width) / 2 / (900 / columns), row: 4 + (360 - height) / 64 }, ext: { width, height }, editAs: 'oneCell' });
|
||||
const caption = merged(chart, 17, 1, columns, `图表为当前页面快照,精确数量见「${title}」工作表。`);
|
||||
caption.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } };
|
||||
chart.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}17`;
|
||||
}
|
||||
headers.forEach((label, i) => { const cell = sheet.getCell(headerRow, i + 1); cell.value = label; cell.fill = fill(palette.navy); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: 'FFFFFFFF' } }; });
|
||||
sheet.getRow(headerRow).height = 30;
|
||||
if (trend) [...report.data.periods].reverse().forEach((row, i) => {
|
||||
const n = start + i;
|
||||
sheet.getRow(n).values = [excelDate(row.period), row.reported, row.closed, row.unclosed, { formula: `IFERROR(C${n}/B${n},0)`, result: row.reported ? row.closed / row.reported : 0 }];
|
||||
sheet.getCell(n, 1).numFmt = report.data.granularity === 'month' ? 'yyyy-mm' : 'yyyy-mm-dd';
|
||||
sheet.getCell(n, 5).numFmt = '0.0%;-0.0%;"—"';
|
||||
});
|
||||
else [...report.data.days].reverse().forEach((row, i) => {
|
||||
sheet.getRow(start + i).values = [excelDate(row.date), excelDate(row.date).toLocaleDateString('zh-CN', { weekday: 'short', timeZone: 'UTC' }), row.reported, row.closed];
|
||||
sheet.getCell(start + i, 1).numFmt = row.date === today ? 'yyyy-mm-dd" 今天"' : 'yyyy-mm-dd';
|
||||
});
|
||||
for (let row = start; row <= end; row++) {
|
||||
const date = sheet.getCell(row, 1).value as Date;
|
||||
const highlight = date.toISOString().slice(0, 10) === today && (!trend || report.data.granularity === 'day');
|
||||
sheet.getRow(row).height = 26;
|
||||
for (let col = 1; col <= columns; col++) {
|
||||
const cell = sheet.getCell(row, col); cell.fill = fill(highlight ? palette.today : (row - start) % 2 ? palette.stripe : palette.white);
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${palette.text}` } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: col === 1 ? 'left' : 'center' };
|
||||
cell.border = { bottom: { style: 'hair', color: { argb: `FF${palette.line}` } } };
|
||||
if (col > (trend ? 1 : 2) && !(trend && col === 5)) {
|
||||
cell.numFmt = '#,##0'; const color = trend ? [palette.blue, palette.green, palette.amber][col - 2] : col === 3 ? palette.blue : palette.green;
|
||||
cell.font = { name: 'Microsoft YaHei', size: 11, color: { argb: `FF${color}` } };
|
||||
}
|
||||
}
|
||||
}
|
||||
sheet.getCell(totalRow, 1).value = '合计';
|
||||
cards.forEach(card => { sheet.getCell(`${card.col}${totalRow}`).value = { formula: sum(card.col), result: card.value }; sheet.getCell(`${card.col}${totalRow}`).numFmt = '#,##0'; });
|
||||
if (trend) { sheet.getCell(totalRow, 5).value = { formula: `IFERROR(C${totalRow}/B${totalRow},0)`, result: report.data.totals.reported ? report.data.totals.closed / report.data.totals.reported : 0 }; sheet.getCell(totalRow, 5).numFmt = '0.0%;-0.0%;"—"'; }
|
||||
for (let col = 1; col <= columns; col++) { const cell = sheet.getCell(totalRow, col); cell.fill = fill('E8EFF9'); cell.font = { name: 'Microsoft YaHei', size: 11, bold: true, color: { argb: `FF${palette.navy}` } }; }
|
||||
sheet.getRow(totalRow).height = 30;
|
||||
const note = merged(sheet, totalRow + 2, 1, columns, trend ? '统计口径:按提报日期统计同批故障;已归档为已闭环,其余状态(含挂起)为未闭环。按月仅统计所选日期范围。' : '统计口径:新增故障按提报日期;解决故障按解决日期统计,不受提报日期限制。同一故障可在不同日期分别计入两个指标。');
|
||||
note.font = { name: 'Microsoft YaHei', size: 10, color: { argb: `FF${palette.muted}` } }; note.alignment = { wrapText: true, vertical: 'middle' }; sheet.getRow(totalRow + 2).height = 36;
|
||||
const source = merged(sheet, bottom, 1, columns, `来源:羚牛氢能 BI 当前可见统计 · 导出时间:${beijingTime(now)}(北京时间)`); source.font = { name: 'Microsoft YaHei', size: 9, color: { argb: `FF${palette.muted}` } };
|
||||
sheet.autoFilter = { from: { row: headerRow, column: 1 }, to: { row: Math.max(headerRow, end), column: columns } };
|
||||
sheet.views = [{ state: 'frozen', ySplit: headerRow, topLeftCell: `A${start}`, showGridLines: false }];
|
||||
sheet.pageSetup.printArea = `A1:${String.fromCharCode(64 + columns)}${bottom}`;
|
||||
sheet.pageSetup.printTitlesRow = `${headerRow}:${headerRow}`;
|
||||
sheet.headerFooter.oddFooter = '&L羚牛氢能 · 故障管理&R第 &P 页 / 共 &N 页';
|
||||
return book;
|
||||
}
|
||||
|
||||
async function chartImage(svg: SVGSVGElement): Promise<ReportChartImage> {
|
||||
const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800;
|
||||
const height = svg.clientHeight || Number(svg.getAttribute('height')) || 240;
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(width)); clone.setAttribute('height', String(height));
|
||||
clone.style.fontFamily = 'Arial, sans-serif';
|
||||
const url = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(clone)], { type: 'image/svg+xml;charset=utf-8' }));
|
||||
try {
|
||||
const image = new Image();
|
||||
await new Promise<void>((resolve, reject) => { image.onload = () => resolve(); image.onerror = () => reject(new Error('图表生成失败')); image.src = url; });
|
||||
const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width * 2); canvas.height = Math.ceil(height * 2);
|
||||
const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('图表生成失败');
|
||||
ctx.scale(2, 2); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, width, height); ctx.drawImage(image, 0, 0, width, height);
|
||||
return { dataUrl: canvas.toDataURL('image/png'), width, height };
|
||||
} finally { URL.revokeObjectURL(url); }
|
||||
}
|
||||
export async function downloadFaultReport(report: FaultReport, svg?: SVGSVGElement) {
|
||||
const png = svg ? await chartImage(svg) : undefined;
|
||||
const book = await createFaultReportWorkbook(report, png);
|
||||
const bytes = await book.xlsx.writeBuffer();
|
||||
const url = URL.createObjectURL(new Blob([new Uint8Array(bytes)], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }));
|
||||
const link = document.createElement('a'); link.href = url;
|
||||
link.download = `${report.kind === 'trend' ? `故障处理趋势_${report.data.granularity === 'day' ? '按日' : '按月'}` : '故障运维周报'}_${report.data.start}_${report.data.end}.xlsx`;
|
||||
document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const query = '(max-width: 767px)';
|
||||
const subscribe = (notify: () => void) => {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener('change', notify);
|
||||
return () => media.removeEventListener('change', notify);
|
||||
};
|
||||
/** Keep the two mobile asset surfaces on the same breakpoint. */
|
||||
export function useMobileLayout() {
|
||||
return useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);
|
||||
}
|
||||
Reference in New Issue
Block a user