@svadmin/core 0.53.0 → 0.55.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 +38 -2
- package/src/pivot.ts +367 -0
- package/src/query-session.svelte.ts +6 -1
- package/src/resource-contract.ts +111 -2
- 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,
|
|
@@ -65,7 +67,7 @@ export {
|
|
|
65
67
|
useCreate, useUpdate, useDelete, useCreateMany, useUpdateMany, useDeleteMany, useForm,
|
|
66
68
|
useInvalidate,
|
|
67
69
|
} from './strict-hooks.svelte';
|
|
68
|
-
export { defineResource, getContractFormFields } from './resource-contract';
|
|
70
|
+
export { defineResource, getContractFormFields, withContractProjection } from './resource-contract';
|
|
69
71
|
export { UpdateManyPartialError } from './update-many-contract';
|
|
70
72
|
export { CreateManyPartialError } from './create-many-contract';
|
|
71
73
|
export type { ContractCreateOptions as UseCreateOptions } from './create-hooks.svelte';
|
|
@@ -75,6 +77,7 @@ export type { ContractUpdateParams as UseUpdateMutateParams } from './update-con
|
|
|
75
77
|
export type { ContractDeleteOptions as UseDeleteOptions } from './delete-hooks.svelte';
|
|
76
78
|
export type { ContractDeleteParams as UseDeleteMutateParams } from './delete-contract';
|
|
77
79
|
export type { ContractFormAction, ContractFormDraft, ContractFormValues } from './resource-contract';
|
|
80
|
+
export type { ContractProjectionOptions } from './resource-contract';
|
|
78
81
|
export type { ResourceContract, ContractSchemas, ContractRecord, ContractId, ContractInput } from './resource-contract';
|
|
79
82
|
export type { ContractFilter, ContractSort } from './strict-hooks.svelte';
|
|
80
83
|
export { defineCommand } from './command-contract';
|
|
@@ -114,6 +117,12 @@ export {
|
|
|
114
117
|
export type { NotificationParams } from './notification.svelte';
|
|
115
118
|
export { t, setLocale, getLocale, getAvailableLocales, addTranslations, useTranslation, setI18nProvider, getI18nProvider, createI18nScope, provideI18nScope, getI18nScope, resetI18n } from './i18n.svelte';
|
|
116
119
|
export type { I18nProvider, I18nScope, I18nScopeOptions } from './i18n.svelte';
|
|
120
|
+
export { buildPivot, buildPivotCacheKey, buildPivotDrilldown, buildPivotExportRequest, decodePivotResult, snapshotPivotQuery, snapshotPivotExportTaskResult, formatPivotValue, PIVOT_LIMITS } from './pivot';
|
|
121
|
+
export type { PivotAggregation, PivotCache, PivotDimension, PivotDimensionValue, PivotOptions, PivotPayload, PivotProvider, PivotQuery, PivotResult, PivotDrilldown, PivotExportRequest, PivotExportTaskResult } from './pivot';
|
|
122
|
+
export { civilDateTimeToInstant, parseDateTimeInstant, formatCivilDateTime } from './date-time';
|
|
123
|
+
export type { DateTimeValueMode, DateTimeDisambiguation } from './date-time';
|
|
124
|
+
export { validateGantt, GANTT_LIMITS } from './gantt';
|
|
125
|
+
export type { GanttTaskShape, GanttValidationError } from './gantt';
|
|
117
126
|
export { audit, auditWithProvider, writeAuditEntry, recordMutationRollback, setAuditHandler, setAuditLogProvider, getAuditLogProvider, withValidatedAuditProvider, AuditError } from './audit';
|
|
118
127
|
export type { AuditLogProvider, AuditLogTransport, AuditCreateParams, AuditQueryParams, AuditDraft } from './audit';
|
|
119
128
|
export { TaskError } from './task-contract';
|
|
@@ -143,7 +152,17 @@ export type {
|
|
|
143
152
|
WebhookSummary,
|
|
144
153
|
} from './enterprise';
|
|
145
154
|
export { assertEnterpriseRequestContext, createEnterpriseRequestContext } from './enterprise';
|
|
155
|
+
export {
|
|
156
|
+
DashboardContractError, decodeDashboardQuery, decodeDashboardSnapshot,
|
|
157
|
+
type DashboardMetric, type DashboardProvider, type DashboardQuery, type DashboardSnapshot, type DashboardWidget,
|
|
158
|
+
} from './dashboard-contract';
|
|
146
159
|
export { setChatProvider, getChatProvider, setChatContext, getChatContext, setAgentProvider, getAgentProvider, registerApproval, resolveApproval, hasPendingApprovals, resetChatProvider, defineAdminTool, decodeAdminToolArgs, executeAdminTool, projectAdminToolSchema } from './chatProvider.svelte';
|
|
160
|
+
export { ENTERPRISE_MATURITY, enterpriseMaturityFor } from './enterprise-maturity';
|
|
161
|
+
export type {
|
|
162
|
+
EnterpriseMaturityEntry,
|
|
163
|
+
EnterpriseMaturityStatus,
|
|
164
|
+
EnterpriseMaturityTier,
|
|
165
|
+
} from './enterprise-maturity';
|
|
147
166
|
export type {
|
|
148
167
|
ChatProvider, ChatMessage, ChatMessagePart, ChatContext, ChatAttachment, ChatSource,
|
|
149
168
|
MessageStatus, ToolState, AgentProvider, AgentEvent, AgentOptions, ApprovalResponseOptions,
|
|
@@ -175,7 +194,7 @@ export type {
|
|
|
175
194
|
DeleteManyParams, DeleteManyResult,
|
|
176
195
|
Pagination, Sort, Filter, Identity,
|
|
177
196
|
ResourceDefinition, ResourceProviderConfig, ResourceTransportConfig, ResourceAdapterConfig,
|
|
178
|
-
FieldDefinition, MenuItem,
|
|
197
|
+
FieldDefinition, DateInputOptions, MenuItem,
|
|
179
198
|
AuthActionResult, CheckResult,
|
|
180
199
|
ResourceTypeMap, ResourceInputMap, ResourceInputOperation, InferResourceInput, KnownResources, InferData,
|
|
181
200
|
BaseRecord, Role, AuditLog
|
|
@@ -190,9 +209,26 @@ export { createCaslAccessControl } from './adapters/casl';
|
|
|
190
209
|
export { createCasbinAccessControl } from './adapters/casbin';
|
|
191
210
|
export type { CasbinAdapterOptions } from './adapters/casbin';
|
|
192
211
|
export { downloadData, toCsv, toJson, toXlsx, parseCSV } from './data-transfer.svelte';
|
|
212
|
+
export { parseSpreadsheetClipboard, serializeSpreadsheetClipboard } from './spreadsheet-clipboard';
|
|
213
|
+
export { parseSpreadsheetWorkbook, serializeSpreadsheetWorkbook, snapshotSpreadsheetWorkbook, SPREADSHEET_WORKBOOK_LIMITS } from './spreadsheet-workbook';
|
|
214
|
+
export type { SpreadsheetCellFormat, SpreadsheetSheet, SpreadsheetWorkbook } from './spreadsheet-workbook';
|
|
215
|
+
export { FILTER_COLLECTION_LIMIT, isNumericFilterField, isCollectionFilterOperator,
|
|
216
|
+
filterOperatorsForField, isFilterScalarValue, isFilterCollectionValue } from './filter-values';
|
|
217
|
+
export {
|
|
218
|
+
assertSchemaFormSchema, prepareSchemaFormValue, readSchemaFormPath,
|
|
219
|
+
matchesSchemaFormCondition, isSchemaFormNodeVisible, validateSchemaFormValue,
|
|
220
|
+
} from './schema-form';
|
|
221
|
+
export type {
|
|
222
|
+
SchemaFormScalar, SchemaFormCondition, SchemaFormSchema, SchemaFormValidationError,
|
|
223
|
+
} from './schema-form';
|
|
193
224
|
export { useExport } from './export-hooks.svelte';
|
|
225
|
+
export { snapshotExportTaskResult, downloadExportArtifact, safeArtifactUrl } from './export-format';
|
|
226
|
+
export type { ExportTaskResult } from './export-format';
|
|
194
227
|
export type { UseExportOptions } from './export-hooks.svelte';
|
|
195
228
|
export { useImport } from './import-hooks.svelte';
|
|
229
|
+
export type { ImportArtifact, ImportArtifactProvider, ImportTaskResult } from './import-contract';
|
|
230
|
+
export { snapshotImportArtifact, snapshotImportTaskResult } from './import-contract';
|
|
231
|
+
export { IMPORT_LIMITS } from './import-contract';
|
|
196
232
|
export type { UseImportOptions, ImportProgress, ImportResult } from './import-hooks.svelte';
|
|
197
233
|
export type { ExportFormat } from './data-transfer.svelte';
|
|
198
234
|
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
|
});
|
package/src/resource-contract.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { CloneType, Kind, TransformKind, Type, type Static, type TObject, type T
|
|
|
2
2
|
import { checkExact, createExactSchemaValidator } from './schema-validation';
|
|
3
3
|
import { TypeGuard } from '@sinclair/typebox/type';
|
|
4
4
|
import { DeleteManyPartialError, HttpError, type DataProvider, type KnownResources, type BaseRecord,
|
|
5
|
-
type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
|
-
type GetOneResult, type GetManyResult } from './types';
|
|
5
|
+
type GetListResult, type GetManyParams, type CreateManyParams, type UpdateManyParams, type DeleteManyParams,
|
|
6
|
+
type GetOneParams, type GetOneResult, type GetManyResult } from './types';
|
|
7
7
|
import { withResourceSchemas, createResourceRequestValidator, type ResourceSchemas } from './resource-schemas';
|
|
8
8
|
import { decodeBaseRecord } from './record-decoder';
|
|
9
9
|
import { snapshotPlainData } from './plain-data';
|
|
@@ -166,6 +166,115 @@ export function parseContractRecord(contract: ResourceContract, value: unknown):
|
|
|
166
166
|
throw new HttpError('Invalid provider response', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' });
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
export interface ContractProjectionOptions {
|
|
170
|
+
/** Supplies the id when the backend record has no declared contract id. */
|
|
171
|
+
readonly getId?: (input: {
|
|
172
|
+
resource: string;
|
|
173
|
+
requestedId: string | number | undefined;
|
|
174
|
+
record: unknown;
|
|
175
|
+
index: number;
|
|
176
|
+
}) => string | number | undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function projectBySchema(schema: TSchema, value: unknown): unknown {
|
|
180
|
+
if (TypeGuard.IsObject(schema)) {
|
|
181
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
|
|
182
|
+
const source = value as Record<string, unknown>;
|
|
183
|
+
const projected: Record<string, unknown> = {};
|
|
184
|
+
for (const [key, property] of Object.entries(schema.properties)) {
|
|
185
|
+
if (Object.hasOwn(source, key)) projected[key] = projectBySchema(property, source[key]);
|
|
186
|
+
}
|
|
187
|
+
return projected;
|
|
188
|
+
}
|
|
189
|
+
if (TypeGuard.IsArray(schema)) {
|
|
190
|
+
return Array.isArray(value) ? value.map(item => projectBySchema(schema.items, item)) : value;
|
|
191
|
+
}
|
|
192
|
+
if (TypeGuard.IsTuple(schema)) {
|
|
193
|
+
return Array.isArray(value)
|
|
194
|
+
? value.map((item, index) => schema.items?.[index] ? projectBySchema(schema.items[index], item) : item)
|
|
195
|
+
: value;
|
|
196
|
+
}
|
|
197
|
+
if (TypeGuard.IsUnion(schema)) {
|
|
198
|
+
const branch = schema.anyOf.find(candidate => checkExact(candidate, value));
|
|
199
|
+
return branch ? projectBySchema(branch, value) : value;
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function projectRecord(
|
|
205
|
+
contract: ResourceContract,
|
|
206
|
+
resource: string,
|
|
207
|
+
record: unknown,
|
|
208
|
+
index: number,
|
|
209
|
+
requestedId: string | number | undefined,
|
|
210
|
+
options: ContractProjectionOptions,
|
|
211
|
+
): unknown {
|
|
212
|
+
const projected = projectBySchema(definitionOf(contract).schemas.record, record);
|
|
213
|
+
if (typeof projected !== 'object' || projected === null || Array.isArray(projected)) return projected;
|
|
214
|
+
const result = projected as Record<string, unknown>;
|
|
215
|
+
if (!Object.hasOwn(result, 'id')) {
|
|
216
|
+
const id = options.getId?.({ resource, requestedId, record, index })
|
|
217
|
+
?? requestedId
|
|
218
|
+
?? (typeof record === 'object' && record !== null && !Array.isArray(record)
|
|
219
|
+
? ['id', '_id', 'uuid', 'key', 'name'].map(key => (record as Record<string, unknown>)[key])
|
|
220
|
+
.find(candidate => typeof candidate === 'string' || typeof candidate === 'number')
|
|
221
|
+
: undefined)
|
|
222
|
+
?? `${resource}:${index}`;
|
|
223
|
+
result['id'] = id;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Projects read responses into the exact closed shape required by resource contracts.
|
|
230
|
+
* Write responses remain untouched so mutation validation keeps its existing semantics.
|
|
231
|
+
*/
|
|
232
|
+
export function withContractProjection(
|
|
233
|
+
provider: DataProvider,
|
|
234
|
+
contracts: Readonly<Record<string, ResourceContract>>,
|
|
235
|
+
options: ContractProjectionOptions = {},
|
|
236
|
+
): Readonly<DataProvider> {
|
|
237
|
+
function contractFor(resource: string): ResourceContract | undefined {
|
|
238
|
+
return contracts[resource];
|
|
239
|
+
}
|
|
240
|
+
function projectList(resource: string, result: GetListResult): GetListResult {
|
|
241
|
+
const contract = contractFor(resource);
|
|
242
|
+
if (!contract) return result;
|
|
243
|
+
return {
|
|
244
|
+
...result,
|
|
245
|
+
data: result.data.map((record, index) =>
|
|
246
|
+
projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function projectMany(resource: string, result: GetManyResult): GetManyResult {
|
|
250
|
+
const contract = contractFor(resource);
|
|
251
|
+
if (!contract) return result;
|
|
252
|
+
return {
|
|
253
|
+
...result,
|
|
254
|
+
data: result.data.map((record, index) =>
|
|
255
|
+
projectRecord(contract, resource, record, index, undefined, options) as BaseRecord),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function projectOne(params: GetOneParams, result: GetOneResult): GetOneResult {
|
|
259
|
+
const contract = contractFor(params.resource);
|
|
260
|
+
return contract
|
|
261
|
+
? { ...result, data: projectRecord(contract, params.resource, result.data, 0, params.id, options) as BaseRecord }
|
|
262
|
+
: result;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const adapted: DataProvider = {
|
|
266
|
+
...provider,
|
|
267
|
+
getList: async params => projectList(params.resource, await provider.getList(params)),
|
|
268
|
+
getOne: async params => projectOne(params, await provider.getOne(params)),
|
|
269
|
+
getApiUrl: () => provider.getApiUrl(),
|
|
270
|
+
};
|
|
271
|
+
if (provider.getMany) {
|
|
272
|
+
const getMany = provider.getMany.bind(provider);
|
|
273
|
+
adapted.getMany = async params => projectMany(params.resource, await getMany(params));
|
|
274
|
+
}
|
|
275
|
+
return Object.freeze(adapted);
|
|
276
|
+
}
|
|
277
|
+
|
|
169
278
|
function idSchema(schemas: ContractSchemas): TSchema {
|
|
170
279
|
const schema = schemas.record.properties['id'];
|
|
171
280
|
if (!schema) throw new HttpError('Missing contract ID schema', 400, undefined, { code: 'INVALID_RESOURCE_CONTRACT' });
|