Files
ln-bi/src/modules/assets/components/analysis/MobileAnalysisControls.tsx
T
shishengliang 097e882886
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): 合入完整故障管理并升级至 v1.2.3
Integrate fault management from fault_1013_improve at 1674f05, including overview, trend and weekly reports, multidimensional analysis, authorized fault details, L1 follow-up, source breakdown and Excel exports. Adapt database imports, cross-layer tests and workbook helpers to the main architecture; retain unrelated main functionality.

Validation: TypeScript check, 502 tests, production build, desktop/mobile interactions and six-sheet Excel export passed.
2026-10-16 18:18:36 +08:00

72 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}