feat(assets): add dimension analysis and authorized user previews
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.
This commit is contained in:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics';
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
type Props = {
view: AnalysisView;
cells: AssetAnalysisCell[];
available: Dimension[];
dimensions: Dimension[];
setDimensions: (value: Dimension[]) => void;
filters: AnalysisFilterValues;
setFilters: (value: AnalysisFilterValues) => void;
reset: () => void;
};
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
const root = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [editor, setEditor] = useState<Dimension | null>(null);
const [draft, setDraft] = useState<string[]>([]);
const [query, setQuery] = useState('');
const activeCount = Object.values(filters).filter(values => values?.length).length;
useEffect(() => {
if (!editor) return;
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setEditor(null); };
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [editor]);
const metric = ANALYSIS_META[view].metric;
const options = useMemo(() => {
if (!editor) return [];
const otherFilters = { ...filters };
delete otherFilters[editor];
const eligible = filterCustomCells(cells, otherFilters, metric);
const counts = new Map<string, number>();
eligible.forEach(cell => counts.set(cell[editor], (counts.get(cell[editor]) || 0) + cell.quantity));
for (const value of filters[editor] || []) if (!counts.has(value)) counts.set(value, 0);
return [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(editor, value) }))
.sort((a,b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))
.filter(option => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
}, [cells, editor, filters, metric, query]);
const closeEditor = () => { setEditor(null); triggerRef.current?.focus(); };
const open = (dimension: Dimension, trigger: HTMLButtonElement) => {
triggerRef.current = trigger;
if (editor === dimension) { setEditor(null); return; }
const bounds = root.current!.getBoundingClientRect();
const button = trigger.getBoundingClientRect();
setPosition({ top: button.bottom - bounds.top + 5, left: Math.max(8, Math.min(button.left - bounds.left, bounds.width - 338)) });
setEditor(dimension); setDraft(filters[dimension] || []); setQuery('');
};
const apply = () => {
if (!editor) return;
const next = { ...filters };
if (draft.length) next[editor] = draft; else delete next[editor];
setFilters(next); closeEditor();
};
return <section ref={root} className="analysis-builder" aria-label="自定义分析设置">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点名称组合统计 · 点箭头搜索筛选</span><button className="analysis-reset" onClick={() => { reset(); setEditor(null); }}><RotateCcw size={12} />重置分析</button></div>
<div className="analysis-dimension-options" role="group" aria-label="选择分析维度">
<button aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); setEditor(null); }}><Filter size={12} />仅筛选</button>
{available.map(dimension => {
const index = dimensions.indexOf(dimension);
const selected = filters[dimension] || [];
const filterLabel = selected.map(value => getDimensionValueLabel(dimension, value)).join('、');
return <div className={`analysis-dimension-column${selected.length ? ' has-filter' : ''}`} key={dimension}>
<button className="analysis-dimension-name" title={dimension === 'region' ? '区域划分与库存统计一致' : undefined} aria-pressed={index >= 0} onClick={() => { setDimensions(index < 0 ? [...dimensions, dimension] : dimensions.filter(item => item !== dimension)); setEditor(null); }}>
{index >= 0 ? <span className="analysis-dimension-order">{index + 1}</span> : <Plus size={12} />}{DIMENSION_LABELS[dimension]}
</button>
<button className="analysis-dimension-dropdown" title={selected.length ? filterLabel : `搜索、筛选${DIMENSION_LABELS[dimension]}`} aria-label={`筛选${DIMENSION_LABELS[dimension]}${selected.length ? `,已选${selected.length}项` : ''}`} aria-expanded={editor === dimension} aria-controls={editor === dimension ? 'analysis-column-filter' : undefined} onClick={event => open(dimension, event.currentTarget)}>
{selected.length > 0 && <><Filter size={11} /><span className="analysis-inline-filter">{selected.length === 1 ? filterLabel : `${selected.length}项`}</span></>}<ChevronDown size={12} />
</button>
</div>;
})}
</div>
{activeCount > 0 && <div className="analysis-active-note"><span>{activeCount} 个维度已筛选 · 同维度多选为“或”,不同维度为“且”</span><button className="analysis-text-button" onClick={() => { setFilters({}); setEditor(null); }}>清空全部筛选</button></div>}
{editor && <div id="analysis-column-filter" className="analysis-filter-editor analysis-column-popover" style={position} role="group" aria-label={`${DIMENSION_LABELS[editor]}筛选菜单`} onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); closeEditor(); } }}>
<div className="analysis-filter-editor-top"><strong>{DIMENSION_LABELS[editor]}</strong><span>搜索与筛选</span><button className="analysis-icon-button" aria-label="关闭筛选编辑" onClick={closeEditor}><X size={15} /></button></div>
<label className="analysis-filter-search"><Search size={13} /><input autoFocus aria-label="搜索筛选值" placeholder={`搜索${DIMENSION_LABELS[editor]}`} value={query} onChange={event => setQuery(event.target.value)} /></label>
<div className="analysis-filter-selection"><button onClick={() => setDraft([...new Set([...draft, ...options.map(option => option.value)])])}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraft([])}>清除选择</button><span>已选 {draft.length} 项</span></div>
<div className="analysis-filter-options" role="group" aria-label={`${DIMENSION_LABELS[editor]}选项`}>
{options.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(event.target.checked ? [...draft,option.value] : draft.filter(value => value !== option.value))} /><span>{option.label}</span><small>{option.count} 台</small></label>)}
{!options.length && <p>没有匹配选项,可调整搜索或其他筛选。</p>}
</div>
<div className="analysis-filter-editor-bottom"><span>未选择时不限制此维度</span><button className="analysis-button" onClick={closeEditor}>取消</button><button className="analysis-primary" onClick={apply}><Check size={13} />应用</button></div>
</div>}
</section>;
}