@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
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { captureAdminContext } from './context.svelte';
|
|
2
|
-
import { definedOptions } from './defined-options';
|
|
3
|
-
import { HttpError } from './types';
|
|
2
|
+
import { definedOptions, definedReactiveOptions } from './defined-options';
|
|
3
|
+
import { HttpError, type TaskProvider, type TaskRecord } from './types';
|
|
4
|
+
import { untrack } from 'svelte';
|
|
5
|
+
import { captureAuthLiveScope } from './auth-hooks.svelte';
|
|
4
6
|
import { contractKey, parseContractRecord, type ResourceContract, type ContractSchemas, type ContractRecord } from './resource-contract';
|
|
5
7
|
import type { ContractFilter, ContractSort } from './strict-hooks.svelte';
|
|
6
8
|
import { captureQueryProvider, snapshotListParams } from './query-snapshot';
|
|
7
9
|
import { decodeBaseRecord, decodeListResult } from './record-decoder';
|
|
8
10
|
import { snapshotPlainData } from './plain-data';
|
|
9
|
-
import { downloadData, type ExportFormat } from './export-format';
|
|
11
|
+
import { downloadData, downloadExportArtifact, snapshotExportTaskResult, safeArtifactUrl, type ExportFormat } from './export-format';
|
|
12
|
+
import { useSubmitTask, useTask } from './task-hooks.svelte';
|
|
10
13
|
|
|
11
14
|
export interface UseExportOptions<S extends ContractSchemas> {
|
|
12
15
|
resource: ResourceContract<S>;
|
|
@@ -19,6 +22,12 @@ export interface UseExportOptions<S extends ContractSchemas> {
|
|
|
19
22
|
format?: ExportFormat;
|
|
20
23
|
meta?: Record<string, unknown>;
|
|
21
24
|
dataProviderName?: string;
|
|
25
|
+
enabled?: boolean;
|
|
26
|
+
taskName?: string;
|
|
27
|
+
taskProvider?: TaskProvider;
|
|
28
|
+
taskIdempotencyKey?: string;
|
|
29
|
+
initialTaskId?: string;
|
|
30
|
+
onTaskSubmitted?: (taskId: string) => void | Promise<void>;
|
|
22
31
|
onError?: (error: HttpError) => void | Promise<void>;
|
|
23
32
|
}
|
|
24
33
|
|
|
@@ -53,6 +62,10 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
53
62
|
let isLoading = $state(false);
|
|
54
63
|
let error = $state<HttpError | null>(null);
|
|
55
64
|
let mounted = true;
|
|
65
|
+
let taskId = $state<string | undefined>(untrack(() => options.initialTaskId));
|
|
66
|
+
let taskHandledId = $state<string | undefined>();
|
|
67
|
+
const submitTask = useSubmitTask();
|
|
68
|
+
const taskMode = $derived(options.taskName !== undefined);
|
|
56
69
|
|
|
57
70
|
function captureScope() {
|
|
58
71
|
const contract = options.resource;
|
|
@@ -63,16 +76,52 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
63
76
|
});
|
|
64
77
|
return {
|
|
65
78
|
contract, ...captured,
|
|
79
|
+
authProvider: context.authProvider, auth: captureAuthLiveScope(context.authProvider),
|
|
80
|
+
router: context.routerProvider, access: context.accessControlProvider,
|
|
81
|
+
taskProvider: options.taskProvider ?? context.taskProvider,
|
|
82
|
+
taskName: options.taskName, idempotencyKey: options.taskIdempotencyKey, format: options.format ?? 'csv',
|
|
83
|
+
taskQueryKey: options.taskName === undefined ? undefined : JSON.stringify(snapshotPlainData({
|
|
84
|
+
filters: options.filters ?? [], sorters: options.sorters ?? [],
|
|
85
|
+
maxItemCount: options.maxItemCount ?? null,
|
|
86
|
+
})),
|
|
66
87
|
key: JSON.stringify([key, captured.source, context.tenantCacheKey?.__svadminTenant, captured.meta]),
|
|
67
88
|
};
|
|
68
89
|
}
|
|
69
90
|
type Scope = ReturnType<typeof captureScope>;
|
|
91
|
+
let taskOrigin = $state.raw<Scope | undefined>(
|
|
92
|
+
untrack(() => {
|
|
93
|
+
try { return taskId && taskMode ? captureScope() : undefined; }
|
|
94
|
+
catch { return undefined; }
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
let taskPolling = $state(true);
|
|
70
98
|
let current: { token: object; scope: Scope; promise: Promise<ContractRecord<S>[]> } | undefined;
|
|
99
|
+
const taskProvider = $derived(options.taskProvider ?? context.taskProvider);
|
|
100
|
+
const ownedTaskId = $derived(taskOrigin && scopeCurrent(taskOrigin) ? taskId : undefined);
|
|
101
|
+
const taskQuery = useTask(definedReactiveOptions({
|
|
102
|
+
get taskId() { return ownedTaskId; },
|
|
103
|
+
get taskProvider() { return taskProvider; },
|
|
104
|
+
get queryOptions() {
|
|
105
|
+
return { enabled: taskMode && !!ownedTaskId && !!taskProvider,
|
|
106
|
+
refetchInterval: ownedTaskId && taskPolling ? 2000 : false as const };
|
|
107
|
+
},
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
function scopeCurrent(scope: Scope, requireEnabled = true): boolean {
|
|
111
|
+
if (!mounted || (requireEnabled && options.enabled === false) || !scope.auth.isCurrent()) return false;
|
|
112
|
+
try {
|
|
113
|
+
const next = captureScope();
|
|
114
|
+
return next.key === scope.key && next.authProvider === scope.authProvider &&
|
|
115
|
+
next.router === scope.router && next.access === scope.access &&
|
|
116
|
+
next.taskName === scope.taskName &&
|
|
117
|
+
(scope.taskName === undefined || (next.taskProvider === scope.taskProvider &&
|
|
118
|
+
next.taskName === scope.taskName && next.idempotencyKey === scope.idempotencyKey &&
|
|
119
|
+
next.format === scope.format && next.taskQueryKey === scope.taskQueryKey));
|
|
120
|
+
} catch { return false; }
|
|
121
|
+
}
|
|
71
122
|
|
|
72
123
|
function isActive(token: object, scope: Scope): boolean {
|
|
73
|
-
|
|
74
|
-
try { return captureScope().key === scope.key; }
|
|
75
|
-
catch { return false; }
|
|
124
|
+
return current?.token === token && scopeCurrent(scope);
|
|
76
125
|
}
|
|
77
126
|
|
|
78
127
|
let initialized = false;
|
|
@@ -89,6 +138,23 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
89
138
|
previousScope = nextScope;
|
|
90
139
|
initialized = true;
|
|
91
140
|
});
|
|
141
|
+
let wasEnabled = untrack(() => options.enabled !== false);
|
|
142
|
+
$effect.pre(() => {
|
|
143
|
+
const enabled = options.enabled !== false;
|
|
144
|
+
const revoked = wasEnabled && !enabled;
|
|
145
|
+
wasEnabled = enabled;
|
|
146
|
+
if (current && !scopeCurrent(current.scope)) {
|
|
147
|
+
current = undefined;
|
|
148
|
+
isLoading = false;
|
|
149
|
+
error = null;
|
|
150
|
+
}
|
|
151
|
+
if (taskOrigin && (revoked || !scopeCurrent(taskOrigin, false))) {
|
|
152
|
+
taskOrigin = undefined;
|
|
153
|
+
taskId = undefined;
|
|
154
|
+
taskHandledId = undefined;
|
|
155
|
+
error = null;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
92
158
|
$effect(() => () => {
|
|
93
159
|
mounted = false;
|
|
94
160
|
current = undefined;
|
|
@@ -106,12 +172,54 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
106
172
|
}
|
|
107
173
|
}
|
|
108
174
|
|
|
175
|
+
function taskStatus(value: TaskRecord | undefined): 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'unknown' {
|
|
176
|
+
const status = String(value?.status ?? '').trim().toLowerCase();
|
|
177
|
+
if (['pending', 'queued', 'scheduled', 'retry_scheduled'].includes(status)) return 'queued';
|
|
178
|
+
if (['running', 'processing', 'active', 'leased'].includes(status)) return 'processing';
|
|
179
|
+
if (['completed', 'complete', 'success', 'succeeded', 'done'].includes(status)) return 'completed';
|
|
180
|
+
if (['failed', 'error', 'dead_lettered'].includes(status)) return 'failed';
|
|
181
|
+
if (['cancelled', 'canceled', 'aborted'].includes(status)) return 'cancelled';
|
|
182
|
+
return 'unknown';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const artifact = $derived.by(() => {
|
|
186
|
+
const task = taskQuery.data;
|
|
187
|
+
if (!ownedTaskId || !task || taskStatus(task) !== 'completed') return undefined;
|
|
188
|
+
const result = snapshotExportTaskResult(task.result ?? task.result_data);
|
|
189
|
+
return result && result.format === taskOrigin?.format && safeArtifactUrl(result.downloadUrl) ? result : undefined;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
$effect(() => {
|
|
193
|
+
const task = taskQuery.data;
|
|
194
|
+
if (!taskMode || !task || !ownedTaskId || taskHandledId === ownedTaskId) return;
|
|
195
|
+
const status = taskStatus(task);
|
|
196
|
+
if (status === 'queued' || status === 'processing' || status === 'unknown') return;
|
|
197
|
+
taskHandledId = ownedTaskId;
|
|
198
|
+
taskPolling = false;
|
|
199
|
+
if (status === 'completed' && !artifact) report(new HttpError(
|
|
200
|
+
'Invalid export task result', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' }), options.onError);
|
|
201
|
+
else if (status === 'failed') report(exportFailure(undefined), options.onError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
function downloadTask(): boolean {
|
|
205
|
+
if (!artifact || !taskOrigin || !scopeCurrent(taskOrigin)) return false;
|
|
206
|
+
try {
|
|
207
|
+
downloadExportArtifact(artifact, taskOrigin.contract.name);
|
|
208
|
+
return true;
|
|
209
|
+
} catch {
|
|
210
|
+
report(new HttpError('Invalid export artifact', 502, undefined, { code: 'INVALID_PROVIDER_RESPONSE' }), options.onError);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
109
215
|
function triggerExport(): Promise<ContractRecord<S>[]> {
|
|
110
216
|
if (!mounted) return Promise.reject(cancelled());
|
|
111
217
|
let onError: UseExportOptions<S>['onError'];
|
|
112
218
|
try {
|
|
113
219
|
const scope = captureScope();
|
|
114
|
-
if (
|
|
220
|
+
if (!scopeCurrent(scope)) return Promise.reject(cancelled());
|
|
221
|
+
if (current && scopeCurrent(current.scope)) return current.promise;
|
|
222
|
+
if (scope.taskName !== undefined && ownedTaskId) return Promise.resolve([]);
|
|
115
223
|
previousScope = scope.key;
|
|
116
224
|
initialized = true;
|
|
117
225
|
const batchSize = options.pageSize ?? 20;
|
|
@@ -119,6 +227,10 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
119
227
|
const format = options.format ?? 'csv';
|
|
120
228
|
const download = options.download ?? true;
|
|
121
229
|
const mapData = options.mapData;
|
|
230
|
+
const taskName = scope.taskName;
|
|
231
|
+
const taskSource = scope.taskProvider;
|
|
232
|
+
const idempotencyKey = scope.idempotencyKey;
|
|
233
|
+
const onTaskSubmitted = options.onTaskSubmitted;
|
|
122
234
|
onError = options.onError;
|
|
123
235
|
if (!Number.isSafeInteger(batchSize) || batchSize <= 0 ||
|
|
124
236
|
(maxItems !== Infinity && (!Number.isSafeInteger(maxItems) || maxItems < 0)) ||
|
|
@@ -141,6 +253,34 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
141
253
|
const promise = Promise.resolve().then(async () => {
|
|
142
254
|
try {
|
|
143
255
|
ensureActive();
|
|
256
|
+
if (taskName !== undefined) {
|
|
257
|
+
if (!taskName.trim() || !taskSource || !idempotencyKey?.trim() || mapData !== undefined) invalidInput();
|
|
258
|
+
const task = await submitTask.mutation.mutateAsync({
|
|
259
|
+
taskName: taskName.trim(),
|
|
260
|
+
taskProvider: taskSource,
|
|
261
|
+
successNotification: false,
|
|
262
|
+
errorNotification: false,
|
|
263
|
+
options: {
|
|
264
|
+
idempotencyKey,
|
|
265
|
+
body: {
|
|
266
|
+
protocolVersion: 1,
|
|
267
|
+
resource: scope.contract.name,
|
|
268
|
+
format,
|
|
269
|
+
filters: params.filters ?? [],
|
|
270
|
+
sorters: params.sorters ?? [],
|
|
271
|
+
...definedOptions(maxItems === Infinity ? {} : { maxItemCount: maxItems }),
|
|
272
|
+
},
|
|
273
|
+
...definedOptions(scope.meta === undefined ? {} : { meta: scope.meta }),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
ensureActive();
|
|
277
|
+
taskId = task.id;
|
|
278
|
+
taskOrigin = scope;
|
|
279
|
+
taskHandledId = undefined;
|
|
280
|
+
taskPolling = true;
|
|
281
|
+
try { void Promise.resolve(onTaskSubmitted?.(task.id)).catch(() => {}); } catch { /* Observer failures do not re-submit. */ }
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
144
284
|
const records: ContractRecord<S>[] = [];
|
|
145
285
|
let page = 1;
|
|
146
286
|
while (records.length < maxItems) {
|
|
@@ -187,5 +327,26 @@ export function useExport<S extends ContractSchemas>(options: UseExportOptions<S
|
|
|
187
327
|
}
|
|
188
328
|
}
|
|
189
329
|
|
|
190
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
triggerExport,
|
|
332
|
+
get isLoading() { return isLoading; },
|
|
333
|
+
get error() { return error; },
|
|
334
|
+
get taskId() { return ownedTaskId; },
|
|
335
|
+
get task() { return ownedTaskId ? taskQuery.data : undefined; },
|
|
336
|
+
get artifact() { return artifact; },
|
|
337
|
+
get taskError() { return ownedTaskId ? taskQuery.error : null; },
|
|
338
|
+
get isTaskPending() {
|
|
339
|
+
return !!ownedTaskId && !['completed', 'failed', 'cancelled'].includes(taskStatus(taskQuery.data));
|
|
340
|
+
},
|
|
341
|
+
get refetchTask() {
|
|
342
|
+
const origin = taskOrigin;
|
|
343
|
+
const id = ownedTaskId;
|
|
344
|
+
const refetch = taskQuery.refetch;
|
|
345
|
+
return () => {
|
|
346
|
+
if (!origin || !id || id !== ownedTaskId || !scopeCurrent(origin)) return Promise.reject(cancelled());
|
|
347
|
+
return refetch();
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
downloadTask,
|
|
351
|
+
};
|
|
191
352
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CrudOperator, FieldDefinition } from './types';
|
|
2
|
+
|
|
3
|
+
export const FILTER_COLLECTION_LIMIT = 100;
|
|
4
|
+
|
|
5
|
+
export function isNumericFilterField(field: FieldDefinition | undefined): boolean {
|
|
6
|
+
return field !== undefined && ['number', 'currency', 'percent', 'rating', 'rate'].includes(field.type);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isCollectionFilterOperator(operator: string): boolean {
|
|
10
|
+
return ['in', 'nin', 'between', 'nbetween'].includes(operator);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function filterOperatorsForField(field: FieldDefinition | undefined): CrudOperator[] {
|
|
14
|
+
if (!field || field.filterable === false) return [];
|
|
15
|
+
if (isNumericFilterField(field)) {
|
|
16
|
+
return ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'in', 'nin', 'between', 'nbetween', 'null', 'nnull'];
|
|
17
|
+
}
|
|
18
|
+
if (field.type === 'boolean' || field.type === 'select') return ['eq', 'ne', 'in', 'nin', 'null', 'nnull'];
|
|
19
|
+
if (['date', 'time', 'datetime'].includes(field.type)) return ['eq', 'ne', 'lt', 'gt', 'lte', 'gte', 'null', 'nnull'];
|
|
20
|
+
return ['eq', 'ne', 'contains', 'ncontains', 'startswith', 'endswith', 'lt', 'gt', 'lte', 'gte', 'in', 'nin', 'null', 'nnull'];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isFilterScalarValue(field: FieldDefinition, value: unknown): boolean {
|
|
24
|
+
if (isNumericFilterField(field)) return typeof value === 'number' && Number.isFinite(value);
|
|
25
|
+
if (field.type === 'boolean') return typeof value === 'boolean';
|
|
26
|
+
if (field.type === 'select') return field.options?.some(option => !option.disabled && option.value === value) === true;
|
|
27
|
+
return typeof value === 'string';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 不排序、不去重、不强制转换;非法集合不能退化为无筛选查询。 */
|
|
31
|
+
export function isFilterCollectionValue(field: FieldDefinition | undefined, operator: string, value: unknown): boolean {
|
|
32
|
+
if (!field || !filterOperatorsForField(field).includes(operator as CrudOperator)
|
|
33
|
+
|| !isCollectionFilterOperator(operator) || !Array.isArray(value)
|
|
34
|
+
|| value.length < 1 || value.length > FILTER_COLLECTION_LIMIT) return false;
|
|
35
|
+
for (let index = 0; index < value.length; index++) {
|
|
36
|
+
if (!Object.hasOwn(value, index) || !isFilterScalarValue(field, value[index])) return false;
|
|
37
|
+
}
|
|
38
|
+
if (operator === 'between' || operator === 'nbetween') {
|
|
39
|
+
return value.length === 2 && typeof value[0] === 'number' && typeof value[1] === 'number' && value[0] <= value[1];
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
package/src/gantt.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface GanttTaskShape {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
startDay: number;
|
|
5
|
+
durationDays: number;
|
|
6
|
+
progress?: number;
|
|
7
|
+
status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
|
8
|
+
dependencies?: string[];
|
|
9
|
+
milestone?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type GanttValidationError = 'invalidData' | 'limit' | 'dependency';
|
|
13
|
+
|
|
14
|
+
export const GANTT_LIMITS = Object.freeze({
|
|
15
|
+
tasks: 1000,
|
|
16
|
+
days: 366,
|
|
17
|
+
cells: 40_000,
|
|
18
|
+
dependencies: 10_000,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function validateGantt<T extends GanttTaskShape>(
|
|
22
|
+
tasks: readonly T[],
|
|
23
|
+
totalDays: number,
|
|
24
|
+
): GanttValidationError | undefined {
|
|
25
|
+
if (!Array.isArray(tasks) || !Number.isSafeInteger(totalDays) || totalDays < 1 || totalDays > GANTT_LIMITS.days) {
|
|
26
|
+
return 'invalidData';
|
|
27
|
+
}
|
|
28
|
+
if (tasks.length > GANTT_LIMITS.tasks || tasks.length * totalDays > GANTT_LIMITS.cells) return 'limit';
|
|
29
|
+
const ids = new Set<string>();
|
|
30
|
+
const byId = new Map<string, T>();
|
|
31
|
+
let edgeCount = 0;
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
if (!task || typeof task.id !== 'string' || !task.id.trim() || ids.has(task.id)
|
|
34
|
+
|| typeof task.title !== 'string' || !task.title.trim()
|
|
35
|
+
|| task.id.length > 1000 || task.title.length > 1000
|
|
36
|
+
|| !Number.isSafeInteger(task.startDay) || task.startDay < 0
|
|
37
|
+
|| !Number.isSafeInteger(task.durationDays)
|
|
38
|
+
|| (task.milestone === true ? task.durationDays !== 0 || task.startDay >= totalDays : task.durationDays < 1)
|
|
39
|
+
|| task.startDay + task.durationDays > totalDays
|
|
40
|
+
|| (task.progress !== undefined && (typeof task.progress !== 'number' || !Number.isFinite(task.progress) || task.progress < 0 || task.progress > 100))
|
|
41
|
+
|| (task.status !== undefined && !['planned', 'in_progress', 'completed', 'delayed'].includes(task.status))) {
|
|
42
|
+
return 'invalidData';
|
|
43
|
+
}
|
|
44
|
+
if ((task.milestone !== undefined && typeof task.milestone !== 'boolean')
|
|
45
|
+
|| (task.dependencies !== undefined && (!Array.isArray(task.dependencies)
|
|
46
|
+
|| task.dependencies.length > GANTT_LIMITS.tasks
|
|
47
|
+
|| new Set(task.dependencies).size !== task.dependencies.length
|
|
48
|
+
|| task.dependencies.some((dependency: unknown) => typeof dependency !== 'string' || !dependency.trim() || dependency.length > 1000)))) {
|
|
49
|
+
return 'invalidData';
|
|
50
|
+
}
|
|
51
|
+
edgeCount += task.dependencies?.length ?? 0;
|
|
52
|
+
if (edgeCount > GANTT_LIMITS.dependencies) return 'limit';
|
|
53
|
+
ids.add(task.id);
|
|
54
|
+
byId.set(task.id, task);
|
|
55
|
+
}
|
|
56
|
+
const successors = new Map<string, string[]>();
|
|
57
|
+
const remaining = new Map<string, number>();
|
|
58
|
+
const ready: string[] = [];
|
|
59
|
+
for (const task of tasks) {
|
|
60
|
+
remaining.set(task.id, task.dependencies?.length ?? 0);
|
|
61
|
+
if (!task.dependencies?.length) ready.push(task.id);
|
|
62
|
+
for (const dependency of task.dependencies ?? []) {
|
|
63
|
+
const parent = byId.get(dependency);
|
|
64
|
+
if (!parent) return 'dependency';
|
|
65
|
+
if (parent.startDay + parent.durationDays > task.startDay) return 'dependency';
|
|
66
|
+
const children = successors.get(dependency) ?? [];
|
|
67
|
+
children.push(task.id);
|
|
68
|
+
successors.set(dependency, children);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// 有界迭代拓扑检查,零时长里程碑成环也必须被拒绝。
|
|
72
|
+
for (let index = 0; index < ready.length; index++) {
|
|
73
|
+
const id = ready[index];
|
|
74
|
+
if (id === undefined) return 'dependency';
|
|
75
|
+
for (const child of successors.get(id) ?? []) {
|
|
76
|
+
const pending = remaining.get(child);
|
|
77
|
+
if (pending === undefined) return 'dependency';
|
|
78
|
+
const count = pending - 1;
|
|
79
|
+
remaining.set(child, count);
|
|
80
|
+
if (count === 0) ready.push(child);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return ready.length === tasks.length ? undefined : 'dependency';
|
|
84
|
+
}
|