ci/woodpecker/push/woodpecker Pipeline was successful
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.
78 lines
5.8 KiB
TypeScript
78 lines
5.8 KiB
TypeScript
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>;
|
||
}
|