@svadmin/core 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -1
- package/src/approval-contract.ts +131 -0
- package/src/dashboard-contract.ts +72 -0
- package/src/date-time.ts +55 -0
- package/src/enterprise-maturity.ts +54 -0
- package/src/export-format.ts +44 -0
- package/src/export-hooks.svelte.ts +169 -8
- package/src/filter-values.ts +42 -0
- package/src/gantt.ts +84 -0
- package/src/i18n.svelte.ts +306 -2
- package/src/import-contract.ts +94 -6
- package/src/import-hooks.svelte.ts +13 -5
- package/src/index.ts +36 -1
- package/src/pivot.ts +367 -0
- package/src/query-session.svelte.ts +6 -1
- package/src/schema-form.ts +204 -0
- package/src/schema.ts +7 -0
- package/src/spreadsheet-clipboard.ts +45 -0
- package/src/spreadsheet-workbook.ts +64 -0
- package/src/spreadsheet-xlsx.ts +181 -0
- package/src/task-hooks.svelte.ts +115 -46
- package/src/types.ts +18 -0
package/src/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// Core barrel exports
|
|
2
|
+
export { decodeApprovalRecord, decodeApprovalList, decodeApprovalTransition, decodeApprovalReceipt, ApprovalContractError } from './approval-contract';
|
|
3
|
+
export type { ApprovalRecord, ApprovalTransition, ApprovalList, ApprovalReceipt, ApprovalProvider } from './approval-contract';
|
|
2
4
|
|
|
3
5
|
export {
|
|
4
6
|
setDataProvider, getDataProvider, getDataProviderForResource, getDataProviderNames,
|
|
@@ -114,6 +116,12 @@ export {
|
|
|
114
116
|
export type { NotificationParams } from './notification.svelte';
|
|
115
117
|
export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation, setI18nProvider, getI18nProvider, createI18nScope, provideI18nScope, getI18nScope, resetI18n } from './i18n.svelte';
|
|
116
118
|
export type { I18nProvider, I18nScope, I18nScopeOptions } from './i18n.svelte';
|
|
119
|
+
export { buildPivot, buildPivotCacheKey, buildPivotDrilldown, buildPivotExportRequest, decodePivotResult, snapshotPivotQuery, snapshotPivotExportTaskResult, formatPivotValue, PIVOT_LIMITS } from './pivot';
|
|
120
|
+
export type { PivotAggregation, PivotCache, PivotDimension, PivotDimensionValue, PivotOptions, PivotPayload, PivotProvider, PivotQuery, PivotResult, PivotDrilldown, PivotExportRequest, PivotExportTaskResult } from './pivot';
|
|
121
|
+
export { civilDateTimeToInstant, parseDateTimeInstant, formatCivilDateTime } from './date-time';
|
|
122
|
+
export type { DateTimeValueMode, DateTimeDisambiguation } from './date-time';
|
|
123
|
+
export { validateGantt, GANTT_LIMITS } from './gantt';
|
|
124
|
+
export type { GanttTaskShape, GanttValidationError } from './gantt';
|
|
117
125
|
export { audit, auditWithProvider, writeAuditEntry, recordMutationRollback, setAuditHandler, setAuditLogProvider, getAuditLogProvider, withValidatedAuditProvider, AuditError } from './audit';
|
|
118
126
|
export type { AuditLogProvider, AuditLogTransport, AuditCreateParams, AuditQueryParams, AuditDraft } from './audit';
|
|
119
127
|
export { TaskError } from './task-contract';
|
|
@@ -143,7 +151,17 @@ export type {
|
|
|
143
151
|
WebhookSummary,
|
|
144
152
|
} from './enterprise';
|
|
145
153
|
export { assertEnterpriseRequestContext, createEnterpriseRequestContext } from './enterprise';
|
|
154
|
+
export {
|
|
155
|
+
DashboardContractError, decodeDashboardQuery, decodeDashboardSnapshot,
|
|
156
|
+
type DashboardMetric, type DashboardProvider, type DashboardQuery, type DashboardSnapshot, type DashboardWidget,
|
|
157
|
+
} from './dashboard-contract';
|
|
146
158
|
export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider, defineAdminTool, decodeAdminToolArgs, executeAdminTool, projectAdminToolSchema } from './chatProvider.svelte';
|
|
159
|
+
export { ENTERPRISE_MATURITY, enterpriseMaturityFor } from './enterprise-maturity';
|
|
160
|
+
export type {
|
|
161
|
+
EnterpriseMaturityEntry,
|
|
162
|
+
EnterpriseMaturityStatus,
|
|
163
|
+
EnterpriseMaturityTier,
|
|
164
|
+
} from './enterprise-maturity';
|
|
147
165
|
export type {
|
|
148
166
|
ChatProvider, ChatMessage, ChatMessagePart, ChatContext, ChatAttachment, ChatSource,
|
|
149
167
|
MessageStatus, ToolState, AgentProvider, AgentEvent, AgentOptions, ApprovalResponseOptions,
|
|
@@ -175,7 +193,7 @@ export type {
|
|
|
175
193
|
DeleteManyParams, DeleteManyResult,
|
|
176
194
|
Pagination, Sort, Filter, Identity,
|
|
177
195
|
ResourceDefinition, ResourceProviderConfig, ResourceTransportConfig, ResourceAdapterConfig,
|
|
178
|
-
FieldDefinition, MenuItem,
|
|
196
|
+
FieldDefinition, DateInputOptions, MenuItem,
|
|
179
197
|
AuthActionResult, CheckResult,
|
|
180
198
|
ResourceTypeMap, ResourceInputMap, ResourceInputOperation, InferResourceInput, KnownResources, InferData,
|
|
181
199
|
BaseRecord, Role, AuditLog
|
|
@@ -190,9 +208,26 @@ export { createCaslAccessControl } from './adapters/casl';
|
|
|
190
208
|
export { createCasbinAccessControl } from './adapters/casbin';
|
|
191
209
|
export type { CasbinAdapterOptions } from './adapters/casbin';
|
|
192
210
|
export { downloadData, toCsv, toJson, toXlsx, parseCSV } from './data-transfer.svelte';
|
|
211
|
+
export { parseSpreadsheetClipboard, serializeSpreadsheetClipboard } from './spreadsheet-clipboard';
|
|
212
|
+
export { parseSpreadsheetWorkbook, serializeSpreadsheetWorkbook, snapshotSpreadsheetWorkbook, SPREADSHEET_WORKBOOK_LIMITS } from './spreadsheet-workbook';
|
|
213
|
+
export type { SpreadsheetCellFormat, SpreadsheetSheet, SpreadsheetWorkbook } from './spreadsheet-workbook';
|
|
214
|
+
export { FILTER_COLLECTION_LIMIT, isNumericFilterField, isCollectionFilterOperator,
|
|
215
|
+
filterOperatorsForField, isFilterScalarValue, isFilterCollectionValue } from './filter-values';
|
|
216
|
+
export {
|
|
217
|
+
assertSchemaFormSchema, prepareSchemaFormValue, readSchemaFormPath,
|
|
218
|
+
matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
|
|
219
|
+
} from './schema-form';
|
|
220
|
+
export type {
|
|
221
|
+
SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
|
|
222
|
+
} from './schema-form';
|
|
193
223
|
export { useExport } from './export-hooks.svelte';
|
|
224
|
+
export { snapshotExportTaskResult, downloadExportArtifact, safeArtifactUrl } from './export-format';
|
|
225
|
+
export type { ExportTaskResult } from './export-format';
|
|
194
226
|
export type { UseExportOptions } from './export-hooks.svelte';
|
|
195
227
|
export { useImport } from './import-hooks.svelte';
|
|
228
|
+
export type { ImportArtifact, ImportArtifactProvider, ImportTaskResult } from './import-contract';
|
|
229
|
+
export { snapshotImportArtifact, snapshotImportTaskResult } from './import-contract';
|
|
230
|
+
export { IMPORT_LIMITS } from './import-contract';
|
|
196
231
|
export type { UseImportOptions, ImportProgress, ImportResult } from './import-hooks.svelte';
|
|
197
232
|
export type { ExportFormat } from './data-transfer.svelte';
|
|
198
233
|
export {
|
package/src/pivot.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { Filter } from './types';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { snapshotListParams } from './query-snapshot';
|
|
4
|
+
import { snapshotExportTaskResult, type ExportFormat, type ExportTaskResult } from './export-format';
|
|
5
|
+
|
|
6
|
+
export type PivotAggregation = 'sum' | 'count' | 'avg' | 'min' | 'max';
|
|
7
|
+
export type PivotDimensionValue = string | number | boolean | null;
|
|
8
|
+
export interface PivotDimension {
|
|
9
|
+
key: string;
|
|
10
|
+
value: PivotDimensionValue;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
export interface PivotOptions {
|
|
14
|
+
rowField: string;
|
|
15
|
+
columnField: string;
|
|
16
|
+
valueField: string;
|
|
17
|
+
aggregator?: PivotAggregation;
|
|
18
|
+
}
|
|
19
|
+
export interface PivotResult {
|
|
20
|
+
error?: 'invalidData' | 'limit' | 'invalidNumber' | 'overflow';
|
|
21
|
+
rows: PivotDimension[];
|
|
22
|
+
columns: PivotDimension[];
|
|
23
|
+
cells: Map<string, Map<string, number | null>>;
|
|
24
|
+
rowTotals: Map<string, number | null>;
|
|
25
|
+
columnTotals: Map<string, number | null>;
|
|
26
|
+
grandTotal: number | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PivotQuery extends PivotOptions {
|
|
30
|
+
resource: string;
|
|
31
|
+
filters?: Filter[];
|
|
32
|
+
meta?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PivotDrilldown {
|
|
36
|
+
resource: string;
|
|
37
|
+
scopeKey: string;
|
|
38
|
+
rowField: string;
|
|
39
|
+
columnField: string;
|
|
40
|
+
valueField: string;
|
|
41
|
+
aggregator: PivotAggregation;
|
|
42
|
+
row: PivotDimension;
|
|
43
|
+
column: PivotDimension;
|
|
44
|
+
filters?: Filter[];
|
|
45
|
+
meta?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PivotProvider {
|
|
49
|
+
aggregate: (query: PivotQuery) => Promise<unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PivotCache {
|
|
53
|
+
/** 相同缓存中的不同数据源必须使用不同命名空间。 */
|
|
54
|
+
providerKey: string;
|
|
55
|
+
get: (key: string) => Promise<unknown | undefined> | unknown | undefined;
|
|
56
|
+
set: (key: string, value: unknown) => Promise<void> | void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildPivotCacheKey(query: unknown, scopeKey: unknown, providerKey: unknown): string | undefined {
|
|
60
|
+
const snapshot = snapshotPivotQuery(query);
|
|
61
|
+
if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000
|
|
62
|
+
|| typeof providerKey !== 'string' || !providerKey.trim() || providerKey.length > 4000) return;
|
|
63
|
+
return JSON.stringify({ protocolVersion: 1, providerKey, scopeKey, query: snapshot });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PivotExportRequest {
|
|
67
|
+
protocolVersion: 1;
|
|
68
|
+
kind: 'pivot';
|
|
69
|
+
scopeKey: string;
|
|
70
|
+
format: ExportFormat;
|
|
71
|
+
query: PivotQuery;
|
|
72
|
+
requestFingerprint: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface PivotExportTaskResult extends ExportTaskResult {
|
|
76
|
+
kind: 'pivot';
|
|
77
|
+
taskName: string;
|
|
78
|
+
idempotencyKey: string;
|
|
79
|
+
scopeKey: string;
|
|
80
|
+
requestFingerprint: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 只生成服务端导出请求,不将当前页或预聚合单元格当作原始记录导出。 */
|
|
84
|
+
export function buildPivotExportRequest(query: unknown, scopeKey: unknown, format: unknown = 'csv'): PivotExportRequest | undefined {
|
|
85
|
+
const snapshot = snapshotPivotQuery(query);
|
|
86
|
+
if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000
|
|
87
|
+
|| (format !== 'csv' && format !== 'json' && format !== 'xlsx')) return;
|
|
88
|
+
const request: Omit<PivotExportRequest, 'requestFingerprint'> = { protocolVersion: 1, kind: 'pivot', scopeKey, format, query: snapshot };
|
|
89
|
+
return { ...request, requestFingerprint: JSON.stringify(request) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function snapshotPivotExportTaskResult(value: unknown, expected: {
|
|
93
|
+
taskName: string; idempotencyKey: string; request: PivotExportRequest;
|
|
94
|
+
}): PivotExportTaskResult | undefined {
|
|
95
|
+
try {
|
|
96
|
+
const plain = snapshotPlainData(value);
|
|
97
|
+
if (!isRecord(plain)) return;
|
|
98
|
+
const { kind, taskName, idempotencyKey, scopeKey, requestFingerprint, ...artifact } = plain;
|
|
99
|
+
const result = snapshotExportTaskResult(artifact);
|
|
100
|
+
if (!result || kind !== 'pivot' || taskName !== expected.taskName
|
|
101
|
+
|| idempotencyKey !== expected.idempotencyKey || scopeKey !== expected.request.scopeKey
|
|
102
|
+
|| requestFingerprint !== expected.request.requestFingerprint
|
|
103
|
+
|| result.format !== expected.request.format) return;
|
|
104
|
+
return { ...result, kind: 'pivot', taskName: expected.taskName,
|
|
105
|
+
idempotencyKey: expected.idempotencyKey, scopeKey: expected.request.scopeKey,
|
|
106
|
+
requestFingerprint: expected.request.requestFingerprint };
|
|
107
|
+
} catch { return; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 保留原条件树的顶层 AND 语义,不能把维度约束塞入已有 OR 组。 */
|
|
111
|
+
export function buildPivotDrilldown(query: unknown, scopeKey: unknown, row: unknown, column: unknown): PivotDrilldown | undefined {
|
|
112
|
+
try {
|
|
113
|
+
const snapshot = snapshotPivotQuery(query);
|
|
114
|
+
if (!snapshot || typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4000) return;
|
|
115
|
+
const rowSnapshot = wireDimension(snapshotPlainData(row));
|
|
116
|
+
const columnSnapshot = wireDimension(snapshotPlainData(column));
|
|
117
|
+
if (!rowSnapshot || !columnSnapshot) return;
|
|
118
|
+
const constraints: Filter[] = [
|
|
119
|
+
{ field: snapshot.rowField, operator: rowSnapshot.value === null ? 'null' : 'eq', value: rowSnapshot.value },
|
|
120
|
+
{ field: snapshot.columnField, operator: columnSnapshot.value === null ? 'null' : 'eq', value: columnSnapshot.value },
|
|
121
|
+
];
|
|
122
|
+
const narrowed = snapshotPivotQuery({ ...snapshot, filters: [...snapshot.filters ?? [], ...constraints] });
|
|
123
|
+
if (!narrowed) return;
|
|
124
|
+
return {
|
|
125
|
+
resource: narrowed.resource,
|
|
126
|
+
rowField: narrowed.rowField,
|
|
127
|
+
columnField: narrowed.columnField,
|
|
128
|
+
valueField: narrowed.valueField,
|
|
129
|
+
aggregator: narrowed.aggregator ?? 'sum',
|
|
130
|
+
scopeKey,
|
|
131
|
+
row: rowSnapshot,
|
|
132
|
+
column: columnSnapshot,
|
|
133
|
+
...(narrowed.filters ? { filters: narrowed.filters } : {}),
|
|
134
|
+
...(narrowed.meta ? { meta: narrowed.meta } : {}),
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 服务端计算所有汇总值;客户端不能对预聚合结果再次求平均或计数。 */
|
|
142
|
+
export interface PivotPayload {
|
|
143
|
+
rows: PivotDimension[];
|
|
144
|
+
columns: PivotDimension[];
|
|
145
|
+
cells: Array<{ rowKey: string; columnKey: string; value: number | null }>;
|
|
146
|
+
rowTotals: Array<{ key: string; value: number | null }>;
|
|
147
|
+
columnTotals: Array<{ key: string; value: number | null }>;
|
|
148
|
+
grandTotal: number | null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function snapshotPivotQuery(value: unknown): PivotQuery | undefined {
|
|
152
|
+
try {
|
|
153
|
+
const plain = snapshotPlainData(value);
|
|
154
|
+
if (!isRecord(plain)) return;
|
|
155
|
+
const { rowField, columnField, valueField, aggregator = 'sum', ...base } = plain;
|
|
156
|
+
if (![rowField, columnField, valueField].every(field => typeof field === 'string'
|
|
157
|
+
&& field.trim().length > 0 && field.length <= PIVOT_LIMITS.dimensionLength)
|
|
158
|
+
|| typeof aggregator !== 'string' || !['sum', 'count', 'avg', 'min', 'max'].includes(aggregator)) return;
|
|
159
|
+
const query = snapshotListParams(base);
|
|
160
|
+
if (Object.keys(base).some(key => !['resource', 'filters', 'meta'].includes(key))) return;
|
|
161
|
+
return {
|
|
162
|
+
...query, rowField: rowField as string, columnField: columnField as string,
|
|
163
|
+
valueField: valueField as string, aggregator: aggregator as PivotAggregation,
|
|
164
|
+
};
|
|
165
|
+
} catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const PIVOT_LIMITS = Object.freeze({
|
|
171
|
+
records: 10_000,
|
|
172
|
+
rows: 200,
|
|
173
|
+
columns: 200,
|
|
174
|
+
cells: 40_000,
|
|
175
|
+
dimensionLength: 1000,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
179
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface Aggregate {
|
|
183
|
+
count: number;
|
|
184
|
+
value: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function dimension(value: unknown): PivotDimension | undefined {
|
|
188
|
+
if (value == null) return { key: 'null:', value: null, label: '—' };
|
|
189
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') return;
|
|
190
|
+
if (typeof value === 'number' && !Number.isFinite(value)) return;
|
|
191
|
+
if (typeof value === 'string' && value.length > PIVOT_LIMITS.dimensionLength) return;
|
|
192
|
+
return { key: `${typeof value}:${value}`, value, label: String(value) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sortedDimensions(values: Map<string, PivotDimension>): PivotDimension[] {
|
|
196
|
+
const labels = new Map<string, number>();
|
|
197
|
+
for (const item of values.values()) labels.set(item.label, (labels.get(item.label) ?? 0) + 1);
|
|
198
|
+
// 同一轴存在歧义时统一转义所有文本,避免加引号后与另一个原始标签重名。
|
|
199
|
+
const quoteStrings = [...labels.values()].some(count => count > 1) || values.has('string:');
|
|
200
|
+
return [...values.values()].sort((a, b) => a.label < b.label ? -1 : a.label > b.label ? 1 : a.key < b.key ? -1 : 1)
|
|
201
|
+
.map(item => ({
|
|
202
|
+
...item,
|
|
203
|
+
label: quoteStrings && typeof item.value === 'string'
|
|
204
|
+
? JSON.stringify(item.value) : item.label,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function result(error?: PivotResult['error']): PivotResult {
|
|
209
|
+
return {
|
|
210
|
+
...(error === undefined ? {} : { error }),
|
|
211
|
+
rows: [], columns: [], cells: new Map(), rowTotals: new Map(), columnTotals: new Map(), grandTotal: null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function wireDimension(value: unknown): PivotDimension | undefined {
|
|
216
|
+
if (!isRecord(value) || typeof value['key'] !== 'string' || typeof value['label'] !== 'string') return;
|
|
217
|
+
const dimensionValue = value['value'];
|
|
218
|
+
if (dimensionValue !== null && typeof dimensionValue !== 'string'
|
|
219
|
+
&& typeof dimensionValue !== 'number' && typeof dimensionValue !== 'boolean') return;
|
|
220
|
+
if (typeof dimensionValue === 'number' && !Number.isFinite(dimensionValue)) return;
|
|
221
|
+
if (!value['key'].trim() || !value['label'].trim()
|
|
222
|
+
|| value['key'].length > PIVOT_LIMITS.dimensionLength || value['label'].length > PIVOT_LIMITS.dimensionLength
|
|
223
|
+
|| (typeof dimensionValue === 'string' && dimensionValue.length > PIVOT_LIMITS.dimensionLength)) return;
|
|
224
|
+
return { key: value['key'], value: dimensionValue, label: value['label'] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function wireNumber(value: unknown): number | null | undefined {
|
|
228
|
+
if (value === null) return null;
|
|
229
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function wireTotals(value: unknown, keys: Set<string>): Map<string, number | null> | undefined {
|
|
233
|
+
if (!Array.isArray(value) || value.length !== keys.size) return;
|
|
234
|
+
const result = new Map<string, number | null>();
|
|
235
|
+
for (const item of value) {
|
|
236
|
+
if (!isRecord(item) || typeof item['key'] !== 'string' || !keys.has(item['key']) || result.has(item['key'])) return;
|
|
237
|
+
const number = wireNumber(item['value']);
|
|
238
|
+
if (number === undefined) return;
|
|
239
|
+
result.set(item['key'], number);
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 服务端汇总回执必须是有界纯数据,不能包含访问器或未知维度引用。 */
|
|
245
|
+
export function decodePivotResult(value: unknown): PivotResult {
|
|
246
|
+
try {
|
|
247
|
+
const plain = snapshotPlainData(value);
|
|
248
|
+
if (!isRecord(plain) || !Array.isArray(plain['rows']) || !Array.isArray(plain['columns'])
|
|
249
|
+
|| !Array.isArray(plain['cells'])) return result('invalidData');
|
|
250
|
+
if (plain['rows'].length > PIVOT_LIMITS.rows || plain['columns'].length > PIVOT_LIMITS.columns
|
|
251
|
+
|| plain['cells'].length > PIVOT_LIMITS.cells) return result('limit');
|
|
252
|
+
const rows: PivotDimension[] = [];
|
|
253
|
+
const columns: PivotDimension[] = [];
|
|
254
|
+
const rowKeys = new Set<string>();
|
|
255
|
+
const columnKeys = new Set<string>();
|
|
256
|
+
for (const item of plain['rows']) {
|
|
257
|
+
const dimension = wireDimension(item);
|
|
258
|
+
if (!dimension || rowKeys.has(dimension.key)) return result('invalidData');
|
|
259
|
+
rowKeys.add(dimension.key);
|
|
260
|
+
rows.push(dimension);
|
|
261
|
+
}
|
|
262
|
+
for (const item of plain['columns']) {
|
|
263
|
+
const dimension = wireDimension(item);
|
|
264
|
+
if (!dimension || columnKeys.has(dimension.key)) return result('invalidData');
|
|
265
|
+
columnKeys.add(dimension.key);
|
|
266
|
+
columns.push(dimension);
|
|
267
|
+
}
|
|
268
|
+
if (rows.length * columns.length > PIVOT_LIMITS.cells) return result('limit');
|
|
269
|
+
const cells = new Map<string, Map<string, number | null>>();
|
|
270
|
+
for (const item of plain['cells']) {
|
|
271
|
+
if (!isRecord(item) || typeof item['rowKey'] !== 'string' || typeof item['columnKey'] !== 'string'
|
|
272
|
+
|| !rowKeys.has(item['rowKey']) || !columnKeys.has(item['columnKey'])) return result('invalidData');
|
|
273
|
+
if (cells.get(item['rowKey'])?.has(item['columnKey'])) return result('invalidData');
|
|
274
|
+
const number = wireNumber(item['value']);
|
|
275
|
+
if (number === undefined) return result('invalidNumber');
|
|
276
|
+
let row = cells.get(item['rowKey']);
|
|
277
|
+
if (!row) { row = new Map(); cells.set(item['rowKey'], row); }
|
|
278
|
+
row.set(item['columnKey'], number);
|
|
279
|
+
}
|
|
280
|
+
const rowTotals = wireTotals(plain['rowTotals'], rowKeys);
|
|
281
|
+
const columnTotals = wireTotals(plain['columnTotals'], columnKeys);
|
|
282
|
+
const grandTotal = wireNumber(plain['grandTotal']);
|
|
283
|
+
if (!rowTotals || !columnTotals || grandTotal === undefined) return result('invalidData');
|
|
284
|
+
return { rows, columns, cells, rowTotals, columnTotals, grandTotal };
|
|
285
|
+
} catch {
|
|
286
|
+
return result('invalidData');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function add(aggregate: Aggregate, value: number, operator: PivotAggregation): boolean {
|
|
291
|
+
aggregate.count++;
|
|
292
|
+
switch (operator) {
|
|
293
|
+
case 'count': aggregate.value = aggregate.count; break;
|
|
294
|
+
case 'sum': aggregate.value += value; break;
|
|
295
|
+
// 分别缩放两项,避免有限输入的均值因中间求和溢出。
|
|
296
|
+
case 'avg': aggregate.value = aggregate.value * ((aggregate.count - 1) / aggregate.count) + value / aggregate.count; break;
|
|
297
|
+
case 'min': aggregate.value = aggregate.count === 1 ? value : Math.min(aggregate.value, value); break;
|
|
298
|
+
case 'max': aggregate.value = aggregate.count === 1 ? value : Math.max(aggregate.value, value); break;
|
|
299
|
+
}
|
|
300
|
+
return Number.isFinite(aggregate.value);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function valueOf(aggregate: Aggregate): number | null {
|
|
304
|
+
return aggregate.count ? aggregate.value : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 有界的记录聚合;不把分页数据解释为整个资源的统计结果。 */
|
|
308
|
+
export function buildPivot(data: readonly Record<string, unknown>[], options: PivotOptions): PivotResult {
|
|
309
|
+
const operator = options.aggregator ?? 'sum';
|
|
310
|
+
if (!Array.isArray(data) || !['sum', 'count', 'avg', 'min', 'max'].includes(operator)
|
|
311
|
+
|| ![options.rowField, options.columnField, options.valueField].every(field => typeof field === 'string' && field.length > 0)) {
|
|
312
|
+
return result('invalidData');
|
|
313
|
+
}
|
|
314
|
+
if (data.length > PIVOT_LIMITS.records) return result('limit');
|
|
315
|
+
const rows = new Map<string, PivotDimension>();
|
|
316
|
+
const columns = new Map<string, PivotDimension>();
|
|
317
|
+
const cells = new Map<string, Map<string, Aggregate>>();
|
|
318
|
+
const rowTotals = new Map<string, Aggregate>();
|
|
319
|
+
const columnTotals = new Map<string, Aggregate>();
|
|
320
|
+
const grand: Aggregate = { count: 0, value: 0 };
|
|
321
|
+
for (const record of data) {
|
|
322
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return result('invalidData');
|
|
323
|
+
const own = (field: string) => Object.hasOwn(record, field) ? record[field] : undefined;
|
|
324
|
+
const row = dimension(own(options.rowField));
|
|
325
|
+
const column = dimension(own(options.columnField));
|
|
326
|
+
if (!row || !column) return result('invalidData');
|
|
327
|
+
rows.set(row.key, row);
|
|
328
|
+
columns.set(column.key, column);
|
|
329
|
+
if (rows.size > PIVOT_LIMITS.rows || columns.size > PIVOT_LIMITS.columns
|
|
330
|
+
|| rows.size * columns.size > PIVOT_LIMITS.cells) return result('limit');
|
|
331
|
+
if (!rowTotals.has(row.key)) rowTotals.set(row.key, { count: 0, value: 0 });
|
|
332
|
+
if (!columnTotals.has(column.key)) columnTotals.set(column.key, { count: 0, value: 0 });
|
|
333
|
+
let rowCells = cells.get(row.key);
|
|
334
|
+
if (!rowCells) { rowCells = new Map(); cells.set(row.key, rowCells); }
|
|
335
|
+
let cell = rowCells.get(column.key);
|
|
336
|
+
if (!cell) { cell = { count: 0, value: 0 }; rowCells.set(column.key, cell); }
|
|
337
|
+
const raw = operator === 'count' ? 1 : own(options.valueField);
|
|
338
|
+
if (raw == null) continue;
|
|
339
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw)) return result('invalidNumber');
|
|
340
|
+
const rowTotal = rowTotals.get(row.key);
|
|
341
|
+
const columnTotal = columnTotals.get(column.key);
|
|
342
|
+
if (!rowTotal || !columnTotal) return result('invalidData');
|
|
343
|
+
const targets = [cell, rowTotal, columnTotal, grand];
|
|
344
|
+
for (const target of targets) {
|
|
345
|
+
if (!add(target, raw, operator)) return result('overflow');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
rows: sortedDimensions(rows),
|
|
350
|
+
columns: sortedDimensions(columns),
|
|
351
|
+
cells: new Map([...cells].map(([key, values]) => [key, new Map([...values].map(([col, agg]) => [col, valueOf(agg)]))])),
|
|
352
|
+
rowTotals: new Map([...rowTotals].map(([key, agg]) => [key, valueOf(agg)])),
|
|
353
|
+
columnTotals: new Map([...columnTotals].map(([key, agg]) => [key, valueOf(agg)])),
|
|
354
|
+
grandTotal: valueOf(grand),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 空格和异常格式化器不能把一个有值的单元格伪装为空值。 */
|
|
359
|
+
export function formatPivotValue(value: number | null | undefined, formatter: (value: number) => string): string {
|
|
360
|
+
if (value == null) return '—';
|
|
361
|
+
try {
|
|
362
|
+
const formatted = formatter(value);
|
|
363
|
+
return typeof formatted === 'string' && formatted.trim() ? formatted : String(value);
|
|
364
|
+
} catch {
|
|
365
|
+
return String(value);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -183,10 +183,15 @@ export function createQuerySession<O extends QuerySessionOptions>(context: Admin
|
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
185
|
}
|
|
186
|
+
// 清理微任务只读取普通快照,不访问已经销毁或退出动画中的派生状态。
|
|
187
|
+
let lastQueryKey: QueryKey | undefined;
|
|
188
|
+
$effect(() => { lastQueryKey = options.queryKey; });
|
|
186
189
|
$effect(() => () => {
|
|
187
190
|
disposed = true;
|
|
191
|
+
const queryKey = lastQueryKey;
|
|
192
|
+
if (!queryKey) return;
|
|
188
193
|
queueMicrotask(() => {
|
|
189
|
-
const query = client.getQueryCache().find({ queryKey
|
|
194
|
+
const query = client.getQueryCache().find({ queryKey, exact: true });
|
|
190
195
|
if (query?.getObserversCount() === 0) void query.cancel();
|
|
191
196
|
});
|
|
192
197
|
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Type, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { Value } from '@sinclair/typebox/value';
|
|
3
|
+
|
|
4
|
+
export type SchemaFormScalar = string | number | boolean | null;
|
|
5
|
+
|
|
6
|
+
/** 条件路径从表单根开始,只能引用无条件的标量字段。 */
|
|
7
|
+
export interface SchemaFormCondition {
|
|
8
|
+
path: readonly string[];
|
|
9
|
+
equals?: SchemaFormScalar;
|
|
10
|
+
notEquals?: SchemaFormScalar;
|
|
11
|
+
exists?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SchemaFormSchema {
|
|
15
|
+
type?: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
properties?: Record<string, SchemaFormSchema>;
|
|
19
|
+
items?: SchemaFormSchema;
|
|
20
|
+
enum?: SchemaFormScalar[];
|
|
21
|
+
default?: unknown;
|
|
22
|
+
required?: string[];
|
|
23
|
+
visibleWhen?: SchemaFormCondition;
|
|
24
|
+
minLength?: number;
|
|
25
|
+
maxLength?: number;
|
|
26
|
+
pattern?: string;
|
|
27
|
+
minimum?: number;
|
|
28
|
+
maximum?: number;
|
|
29
|
+
minItems?: number;
|
|
30
|
+
maxItems?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const keys = new Set([
|
|
34
|
+
'type', 'title', 'description', 'properties', 'items', 'enum', 'default', 'required',
|
|
35
|
+
'visibleWhen', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum', 'minItems', 'maxItems',
|
|
36
|
+
]);
|
|
37
|
+
const scalar = (value: unknown): value is SchemaFormScalar => value === null ||
|
|
38
|
+
typeof value === 'string' || typeof value === 'boolean' ||
|
|
39
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
40
|
+
const fail = (): never => { throw new TypeError('Invalid schema form configuration.'); };
|
|
41
|
+
|
|
42
|
+
/** Schema 来自可信宿主,但仍拒绝未知关键字、循环、错误约束和超限结构。 */
|
|
43
|
+
export function assertSchemaFormSchema(schema: SchemaFormSchema): void {
|
|
44
|
+
let visited = 0;
|
|
45
|
+
const active = new Set<object>();
|
|
46
|
+
function visit(node: SchemaFormSchema, depth: number): void {
|
|
47
|
+
if (!node || typeof node !== 'object' || Array.isArray(node) ||
|
|
48
|
+
++visited > 1000 || depth > 32 || active.has(node)) fail();
|
|
49
|
+
if (Object.keys(node).some(key => !keys.has(key))) fail();
|
|
50
|
+
active.add(node);
|
|
51
|
+
const kind = node.type ?? (node.properties ? 'object' : undefined);
|
|
52
|
+
if (kind !== undefined && !['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(kind)) fail();
|
|
53
|
+
if (node.properties !== undefined) {
|
|
54
|
+
if (kind !== 'object' || !node.properties || typeof node.properties !== 'object' || Array.isArray(node.properties)) fail();
|
|
55
|
+
for (const child of Object.values(node.properties)) visit(child, depth + 1);
|
|
56
|
+
}
|
|
57
|
+
if (node.items !== undefined) {
|
|
58
|
+
if (kind !== 'array') fail();
|
|
59
|
+
visit(node.items, depth + 1);
|
|
60
|
+
if (node.items.visibleWhen !== undefined) fail();
|
|
61
|
+
}
|
|
62
|
+
if (node.required !== undefined && (kind !== 'object' || !Array.isArray(node.required) ||
|
|
63
|
+
node.required.some(key => typeof key !== 'string' || !Object.hasOwn(node.properties ?? {}, key)))) fail();
|
|
64
|
+
if (node.enum !== undefined && (!Array.isArray(node.enum) || node.enum.length === 0 ||
|
|
65
|
+
node.enum.length > 1000 || !node.enum.every(scalar) || kind === 'object' || kind === 'array')) fail();
|
|
66
|
+
for (const key of ['minLength', 'maxLength', 'minItems', 'maxItems'] as const) {
|
|
67
|
+
const limit = node[key];
|
|
68
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0 ||
|
|
69
|
+
(key.endsWith('Length') ? kind !== 'string' : kind !== 'array'))) fail();
|
|
70
|
+
}
|
|
71
|
+
for (const key of ['minimum', 'maximum'] as const) {
|
|
72
|
+
if (node[key] !== undefined && (!Number.isFinite(node[key]) || (kind !== 'number' && kind !== 'integer'))) fail();
|
|
73
|
+
}
|
|
74
|
+
if (node.pattern !== undefined) {
|
|
75
|
+
if (kind !== 'string' || typeof node.pattern !== 'string' || node.pattern.length > 1000) fail();
|
|
76
|
+
try { new RegExp(node.pattern); } catch { fail(); }
|
|
77
|
+
}
|
|
78
|
+
for (const [min, max] of [[node.minLength, node.maxLength], [node.minItems, node.maxItems], [node.minimum, node.maximum]]) {
|
|
79
|
+
if (min !== undefined && max !== undefined && min > max) fail();
|
|
80
|
+
}
|
|
81
|
+
const condition = node.visibleWhen;
|
|
82
|
+
if (condition !== undefined) {
|
|
83
|
+
if (!condition || typeof condition !== 'object' || Array.isArray(condition) ||
|
|
84
|
+
!Array.isArray(condition.path) || condition.path.length === 0 || condition.path.length > 32 ||
|
|
85
|
+
condition.path.some(part => typeof part !== 'string') ||
|
|
86
|
+
Object.keys(condition).some(key => !['path', 'equals', 'notEquals', 'exists'].includes(key)) ||
|
|
87
|
+
!['equals', 'notEquals', 'exists'].some(key => Object.hasOwn(condition, key))) fail();
|
|
88
|
+
if ((Object.hasOwn(condition, 'equals') && !scalar(condition.equals)) ||
|
|
89
|
+
(Object.hasOwn(condition, 'notEquals') && !scalar(condition.notEquals)) ||
|
|
90
|
+
(Object.hasOwn(condition, 'exists') && typeof condition.exists !== 'boolean')) fail();
|
|
91
|
+
let controller = schema;
|
|
92
|
+
for (const part of condition.path) {
|
|
93
|
+
if (controller.visibleWhen || !controller.properties || !Object.hasOwn(controller.properties, part)) fail();
|
|
94
|
+
const next = controller.properties?.[part];
|
|
95
|
+
if (!next) return fail();
|
|
96
|
+
controller = next;
|
|
97
|
+
}
|
|
98
|
+
if (controller.visibleWhen || controller.properties || controller.type === 'object' || controller.type === 'array') fail();
|
|
99
|
+
}
|
|
100
|
+
active.delete(node);
|
|
101
|
+
}
|
|
102
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema) ||
|
|
103
|
+
(schema.type !== 'object' && !schema.properties) || schema.visibleWhen) fail();
|
|
104
|
+
visit(schema, 0);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readSchemaFormPath(value: unknown, path: readonly string[]): unknown {
|
|
108
|
+
return path.reduce<unknown>((current, key) => (
|
|
109
|
+
current !== null && typeof current === 'object' && Object.hasOwn(current, key)
|
|
110
|
+
? (current as Record<string, unknown>)[key] : undefined
|
|
111
|
+
), value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function matchesSchemaFormCondition(condition: SchemaFormCondition | undefined, value: unknown): boolean {
|
|
115
|
+
if (!condition) return true;
|
|
116
|
+
const current = readSchemaFormPath(value, condition.path);
|
|
117
|
+
if (condition.exists !== undefined && (current !== undefined) !== condition.exists) return false;
|
|
118
|
+
if (Object.hasOwn(condition, 'equals') && !Object.is(current, condition.equals)) return false;
|
|
119
|
+
if (Object.hasOwn(condition, 'notEquals') && Object.is(current, condition.notEquals)) return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isSchemaFormNodeVisible(schema: SchemaFormSchema, rootValue: unknown): boolean {
|
|
124
|
+
return matchesSchemaFormCondition(schema.visibleWhen, rootValue);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SchemaFormValidationError {
|
|
128
|
+
/** JSON 数组路径,不混淆字段名中的点、斜杠和数组索引。 */
|
|
129
|
+
path: string;
|
|
130
|
+
code: 'invalid';
|
|
131
|
+
message: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 不编译代码、不转换类型;值约束复用 TypeBox 的解释型校验。 */
|
|
135
|
+
export function prepareSchemaFormValue(schema: SchemaFormSchema, value: unknown): {
|
|
136
|
+
data: Record<string, unknown>;
|
|
137
|
+
errors: SchemaFormValidationError[];
|
|
138
|
+
} {
|
|
139
|
+
assertSchemaFormSchema(schema);
|
|
140
|
+
const errors: SchemaFormValidationError[] = [];
|
|
141
|
+
let visited = 0;
|
|
142
|
+
const invalid = (path: string[]) => {
|
|
143
|
+
if (errors.length < 50) errors.push({ path: JSON.stringify(path), code: 'invalid', message: 'Invalid value.' });
|
|
144
|
+
};
|
|
145
|
+
function visit(node: SchemaFormSchema, current: unknown, path: string[], required: boolean): unknown {
|
|
146
|
+
if (++visited > 1000 || path.length > 32) throw new TypeError('Schema form data limit exceeded.');
|
|
147
|
+
if (current === undefined && !required) return undefined;
|
|
148
|
+
const kind = node.type ?? (node.properties ? 'object' : node.enum ? undefined : 'string');
|
|
149
|
+
let check: TSchema;
|
|
150
|
+
switch (kind) {
|
|
151
|
+
case 'object': check = Type.Object({}); break;
|
|
152
|
+
case 'array': check = Type.Array(Type.Unknown(), {
|
|
153
|
+
...(node.minItems === undefined ? {} : { minItems: node.minItems }),
|
|
154
|
+
...(node.maxItems === undefined ? {} : { maxItems: node.maxItems }),
|
|
155
|
+
}); break;
|
|
156
|
+
case 'number': check = Type.Number({
|
|
157
|
+
...(node.minimum === undefined ? {} : { minimum: node.minimum }),
|
|
158
|
+
...(node.maximum === undefined ? {} : { maximum: node.maximum }),
|
|
159
|
+
}); break;
|
|
160
|
+
case 'integer': check = Type.Integer({
|
|
161
|
+
minimum: Math.max(Number.MIN_SAFE_INTEGER, node.minimum ?? Number.MIN_SAFE_INTEGER),
|
|
162
|
+
maximum: Math.min(Number.MAX_SAFE_INTEGER, node.maximum ?? Number.MAX_SAFE_INTEGER),
|
|
163
|
+
}); break;
|
|
164
|
+
case 'boolean': check = Type.Boolean(); break;
|
|
165
|
+
case 'null': check = Type.Null(); break;
|
|
166
|
+
case 'string': check = Type.String({
|
|
167
|
+
...(node.minLength === undefined ? {} : { minLength: node.minLength }),
|
|
168
|
+
...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }),
|
|
169
|
+
...(node.pattern === undefined ? {} : { pattern: node.pattern }),
|
|
170
|
+
}); break;
|
|
171
|
+
default: check = Type.Unknown();
|
|
172
|
+
}
|
|
173
|
+
if (node.enum) check = Type.Intersect([check, Type.Union(node.enum.map(item => item === null ? Type.Null() : Type.Literal(item)))]);
|
|
174
|
+
if (!Value.Check(check, current)) invalid(path);
|
|
175
|
+
if (kind === 'array' && Array.isArray(current)) {
|
|
176
|
+
if (current.length > 1000 - visited) throw new TypeError('Schema form data limit exceeded.');
|
|
177
|
+
return current.map((item, index) => visit(node.items ?? { type: 'string' }, item, [...path, String(index)], true));
|
|
178
|
+
}
|
|
179
|
+
if (kind === 'object' && current !== null && typeof current === 'object' && !Array.isArray(current)) {
|
|
180
|
+
const source = current as Record<string, unknown>;
|
|
181
|
+
const result: Record<string, unknown> = {};
|
|
182
|
+
for (const key of Object.keys(source)) {
|
|
183
|
+
if (!Object.hasOwn(node.properties ?? {}, key)) invalid([...path, key]);
|
|
184
|
+
}
|
|
185
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
186
|
+
if (!isSchemaFormNodeVisible(child, value)) continue;
|
|
187
|
+
const own = Object.hasOwn(source, key);
|
|
188
|
+
const next = visit(child, own ? source[key] : undefined, [...path, key], node.required?.includes(key) ?? false);
|
|
189
|
+
if (own || next !== undefined) Object.defineProperty(result, key, { value: next, enumerable: true, writable: true, configurable: true });
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
return current;
|
|
194
|
+
}
|
|
195
|
+
const projected = visit(schema, value, [], true);
|
|
196
|
+
return {
|
|
197
|
+
data: projected !== null && typeof projected === 'object' && !Array.isArray(projected) ? projected as Record<string, unknown> : {},
|
|
198
|
+
errors,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function validateSchemaFormValue(schema: SchemaFormSchema, value: unknown): SchemaFormValidationError[] {
|
|
203
|
+
return prepareSchemaFormValue(schema, value).errors;
|
|
204
|
+
}
|