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
+97
View File
@@ -0,0 +1,97 @@
type GroupSort = 'count-desc' | 'count-asc' | 'label';
export function dimensionCountUnit(dimension: string): string {
if (dimension === 'customer') return '家';
if (dimension === 'manager') return '位';
if (dimension === 'date') return '天';
if (['type', 'model', 'assetStatus', 'assetDisposition', 'operationKind', 'inventoryState', 'abnormalType', 'contract_type'].includes(dimension)) return '种';
if (dimension === 'abnormalAge') return '组';
return '个';
}
/** Distinct immediate children within each full parent prefix, across all filtered results. */
export function dimensionChildCounts<T>(rows: readonly T[], dimensionCount: number, values: (row: T) => readonly string[]): number[][] {
const children = new Map<string, Set<string>>();
const keys = rows.map(row => {
const parts = values(row);
return Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => {
const key = JSON.stringify(parts.slice(0, depth + 1));
const set = children.get(key) ?? new Set<string>();
set.add(parts[depth + 1]);
children.set(key, set);
return key;
});
});
return keys.map(row => row.map(key => children.get(key)!.size));
}
/** Use all filtered groups before rendering a prefix so each parent includes every descendant. */
export function dimensionParentTotals<T>(
rows: readonly T[],
dimensionCount: number,
values: (row: T) => readonly string[],
count: (row: T) => number,
): number[][] {
const keys = rows.map(row => Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => JSON.stringify(values(row).slice(0, depth + 1))));
const totals = new Map<string, number>();
rows.forEach((row, index) => {
for (const key of keys[index]) totals.set(key, (totals.get(key) || 0) + count(row));
});
return keys.map(row => row.map(key => totals.get(key) || 0));
}
/** Sort siblings by their combined count, keeping every parent subtree contiguous. */
export function orderDimensionGroups<T>(
rows: readonly T[],
dimensionCount: number,
values: (row: T) => readonly string[],
count: (row: T) => number,
sort: GroupSort = 'count-desc',
compareAt?: (a: T, b: T, index: number) => number,
): T[] {
const visit = (siblings: readonly T[], depth: number): T[] => {
if (depth >= dimensionCount || siblings.length < 2) return [...siblings];
const groups = new Map<string, { rows: T[]; count: number }>();
for (const row of siblings) {
const key = values(row)[depth];
const group = groups.get(key) ?? { rows: [], count: 0 };
group.rows.push(row);
group.count += count(row);
groups.set(key, group);
}
return [...groups.values()].sort((a, b) => {
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
return difference || (compareAt
? compareAt(a.rows[0], b.rows[0], depth)
: values(a.rows[0])[depth].localeCompare(values(b.rows[0])[depth], 'zh-CN', { numeric: true }));
}).flatMap(group => visit(group.rows, depth + 1));
};
return visit(rows, 0);
}
/** A child is merged only within its full parent prefix. */
export function dimensionRowSpans<T>(
rows: readonly T[],
dimensionCount: number,
values: (row: T) => readonly string[],
): number[][] {
const keys = rows.map(values);
const spans = rows.map(() => Array<number>(dimensionCount).fill(1));
for (let dimension = 0; dimension < dimensionCount - 1; dimension += 1) {
let start = 0;
while (start < rows.length) {
let end = start + 1;
while (end < rows.length && keys[start].slice(0, dimension + 1).every((value, index) => value === keys[end][index])) end += 1;
spans[start][dimension] = end - start;
for (let row = start + 1; row < end; row += 1) spans[row][dimension] = 0;
start = end;
}
}
return spans;
}
/** Grow parent cells with the rendered prefix while reusing the complete span calculation. */
export function visibleDimensionRowSpans(spans: readonly (readonly number[])[], visibleCount: number): number[][] {
const count = Math.min(spans.length, Math.max(0, Math.floor(visibleCount)));
return spans.slice(0, count).map((row, index) => row.map(span => Math.min(span, count - index)));
}