Files
ln-bi/src/modules/assets/components/analysis/DimensionPicker.tsx
T
shishengliang f1b3f8d3da
ci/woodpecker/push/woodpecker Pipeline was successful
feat(assets): add dimension analysis and authorized user previews
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
2026-09-28 12:29:51 +08:00

78 lines
5.8 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 { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, Filter, Search, X } from 'lucide-react';
import './dimension-picker.css';
export interface DimensionPickerOption { value: string; label: string; count?: number }
export interface DimensionPickerProps {
label: string;
grouped: boolean;
order?: number;
options: DimensionPickerOption[];
selectedValues?: string[];
onToggleGroup: () => void;
onApplyFilter: (values: string[] | undefined) => void;
}
/** A grouping switch and an independently applied, searchable multi-value filter. */
export function DimensionPicker({ label, grouped, order, options, selectedValues, onToggleGroup, onApplyFilter }: DimensionPickerProps) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 420 });
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const menuId = useId();
const selected = selectedValues ?? [];
const labels = new Map(options.map(option => [option.value, option.label]));
const selectedLabel = selected.map(value => labels.get(value) || value || '未标注').join('、');
const visible = options.filter(option => option.label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const close = (restoreFocus = true) => { setOpen(false); if (restoreFocus) trigger.current?.focus(); };
const show = () => {
if (open) { close(); return; }
setDraft([...selected]); setQuery(''); setOpen(true);
};
useEffect(() => {
if (!open) return;
const place = () => {
const rect = trigger.current?.getBoundingClientRect();
if (!rect) return;
const width = Math.min(320, window.innerWidth - 16);
const roomBelow = window.innerHeight - rect.bottom - 12;
const useAbove = roomBelow < 220 && rect.top > roomBelow;
const height = Math.min(420, Math.max(160, useAbove ? rect.top - 12 : roomBelow));
setPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)), top: useAbove ? Math.max(8, rect.top - height - 5) : rect.bottom + 5, maxHeight: height });
};
place();
const frame = requestAnimationFrame(() => search.current?.focus());
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); };
document.addEventListener('pointerdown', outside);
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); };
}, [open]);
return <div ref={root} className={`analysis-dimension-picker${grouped ? ' adp-grouped' : ''}${selected.length ? ' adp-filtered' : ''}`}>
<div className="adp-split">
<button type="button" className="adp-name" aria-pressed={grouped} onClick={() => { setOpen(false); onToggleGroup(); }} title={`点击${grouped ? '取消' : '加入'}${label}分组`}>
{grouped && order !== undefined ? <span className="adp-order">{order}</span> : null}{label}
</button>
<button ref={trigger} type="button" className="adp-arrow" aria-label={`筛选${label}`} aria-expanded={open} aria-controls={open ? menuId : undefined} aria-haspopup="dialog" onClick={show} title={selected.length ? `${label}已筛选 ${selected.length} 项:${selectedLabel}` : `筛选${label}`}>
{selected.length ? <><Filter size={11} /><span>{selected.length}</span></> : null}<ChevronDown size={12} />
</button>
</div>
{selected.length ? <button type="button" className="adp-selected" onClick={show} title={selectedLabel}>{selected.length === 1 ? selectedLabel : `${selectedLabel}(${selected.length} 项)`}</button> : null}
{open ? <div id={menuId} role="dialog" aria-label={`${label}筛选菜单`} className="adp-menu" style={{ left: position.left, top: position.top, maxHeight: position.maxHeight }} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); } }}>
<div className="adp-menu-heading"><strong>{label}</strong><button type="button" aria-label={`关闭${label}筛选`} onClick={() => close()}><X size={14} /></button></div>
<label className="adp-search"><Search size={13} /><input ref={search} aria-label={`搜索${label}筛选值`} value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索选项" /></label>
<div className="adp-select-tools"><button type="button" disabled={!visible.length} onClick={() => setDraft(current => [...new Set([...current, ...visible.map(option => option.value)])])}>全选搜索结果</button><button type="button" onClick={() => setDraft([])}>清除选择</button><span>{draft.length} 项已选</span></div>
<div className="adp-options" role="group" aria-label={`${label}可选值`}>
{visible.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(current => event.target.checked ? [...current, option.value] : current.filter(value => value !== option.value))} /><span>{option.label}</span>{option.count !== undefined ? <small>{option.count.toLocaleString('zh-CN')}</small> : null}</label>)}
{!visible.length ? <p>没有匹配选项</p> : null}
</div>
<div className="adp-menu-footer"><span>同维度多选为“或”;未选表示不限</span><div><button type="button" onClick={() => close()}>取消</button><button type="button" className="adp-apply" onClick={() => { onApplyFilter(draft.length ? draft : undefined); close(); }}>应用筛选</button></div></div>
</div> : null}
</div>;
}