@svadmin/lite 0.12.0 → 0.14.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/README.md +1 -1
- package/dist/components/LiteErrorSummary.svelte +34 -0
- package/dist/components/LiteErrorSummary.svelte.d.ts +14 -0
- package/dist/components/LiteFileUpload.svelte +53 -0
- package/dist/components/LiteFileUpload.svelte.d.ts +16 -0
- package/dist/components/LiteFilterBuilder.svelte +168 -155
- package/dist/components/LiteFilterBuilder.svelte.d.ts +0 -1
- package/dist/components/LiteGanttChart.svelte +43 -10
- package/dist/components/LiteGanttChart.svelte.d.ts +6 -0
- package/dist/components/LiteJsonSchemaForm.svelte +182 -72
- package/dist/components/LiteJsonSchemaForm.svelte.d.ts +3 -2
- package/dist/components/LiteKanbanBoard.svelte +56 -8
- package/dist/components/LiteKanbanBoard.svelte.d.ts +4 -0
- package/dist/components/LitePivotTable.svelte +88 -58
- package/dist/components/LitePivotTable.svelte.d.ts +13 -1
- package/dist/components/LiteSpreadsheetView.svelte +55 -9
- package/dist/components/LiteSpreadsheetView.svelte.d.ts +8 -0
- package/dist/filter-form.d.ts +3 -0
- package/dist/filter-form.js +34 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/schema-form-data.d.ts +18 -0
- package/dist/schema-form-data.js +264 -0
- package/dist/server-adapter.js +170 -2
- package/package.json +2 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
export type AggregationFn = '
|
|
1
|
+
export type AggregationFn = import('@svadmin/core/pivot').PivotAggregation;
|
|
2
2
|
interface Props {
|
|
3
3
|
data?: Record<string, unknown>[];
|
|
4
|
+
aggregateData?: unknown;
|
|
4
5
|
rowField: string;
|
|
5
6
|
rowLabel?: string;
|
|
6
7
|
columnField: string;
|
|
@@ -8,6 +9,17 @@ interface Props {
|
|
|
8
9
|
valueField: string;
|
|
9
10
|
valueLabel?: string;
|
|
10
11
|
aggregator?: AggregationFn;
|
|
12
|
+
formatValue?: (value: number) => string;
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
error?: string | undefined;
|
|
15
|
+
retryHref?: string;
|
|
16
|
+
exportHref?: string;
|
|
17
|
+
drilldownHref?: (input: import('@svadmin/core/pivot').PivotDrilldown) => string | undefined;
|
|
18
|
+
resource?: string;
|
|
19
|
+
scopeKey?: string;
|
|
20
|
+
filters?: import('@svadmin/core').Filter[];
|
|
21
|
+
meta?: Record<string, unknown>;
|
|
22
|
+
ariaLabel?: string;
|
|
11
23
|
class?: string;
|
|
12
24
|
}
|
|
13
25
|
declare const LitePivotTable: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
const i18n = useTranslation();
|
|
4
|
+
|
|
2
5
|
export interface SheetData {
|
|
3
6
|
id: string;
|
|
4
7
|
name: string;
|
|
@@ -11,6 +14,8 @@
|
|
|
11
14
|
sheets?: SheetData[];
|
|
12
15
|
activeSheetId?: string;
|
|
13
16
|
formAction?: string;
|
|
17
|
+
readonly?: boolean;
|
|
18
|
+
limits?: Partial<{ maxRows: number; maxCols: number; maxCells: number; maxSheets: number; maxCellLength: number }>;
|
|
14
19
|
class?: string;
|
|
15
20
|
}
|
|
16
21
|
|
|
@@ -26,25 +31,63 @@
|
|
|
26
31
|
],
|
|
27
32
|
activeSheetId = 'sheet1',
|
|
28
33
|
formAction = '',
|
|
34
|
+
readonly = false,
|
|
35
|
+
limits: limitOverrides = {},
|
|
29
36
|
class: className = '',
|
|
30
37
|
}: Props = $props();
|
|
31
38
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const limits = $derived.by(() => {
|
|
40
|
+
const bounded = (value: number | undefined, ceiling: number) =>
|
|
41
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? Math.min(value, ceiling) : ceiling;
|
|
42
|
+
return {
|
|
43
|
+
maxRows: bounded(limitOverrides.maxRows, 1000),
|
|
44
|
+
maxCols: bounded(limitOverrides.maxCols, 100),
|
|
45
|
+
maxCells: bounded(limitOverrides.maxCells, 10_000),
|
|
46
|
+
maxSheets: bounded(limitOverrides.maxSheets, 32),
|
|
47
|
+
maxCellLength: bounded(limitOverrides.maxCellLength, 10_000),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const validWorkbook = $derived(sheets.length <= limits.maxSheets && new Set(sheets.map(sheet => sheet.id)).size === sheets.length);
|
|
51
|
+
const currentSheet = $derived(validWorkbook ? (sheets.find(s => s.id === activeSheetId) ?? sheets[0]) : undefined);
|
|
52
|
+
const canRender = $derived.by(() => {
|
|
53
|
+
if (!currentSheet || !currentSheet.cells || typeof currentSheet.cells !== 'object'
|
|
54
|
+
|| !Number.isSafeInteger(currentSheet.rows) || currentSheet.rows < 1 || currentSheet.rows > limits.maxRows
|
|
55
|
+
|| !Number.isSafeInteger(currentSheet.cols) || currentSheet.cols < 1 || currentSheet.cols > limits.maxCols
|
|
56
|
+
|| currentSheet.rows * currentSheet.cols > limits.maxCells) return false;
|
|
57
|
+
let textLength = 0;
|
|
58
|
+
for (let row = 1; row <= currentSheet.rows; row++) {
|
|
59
|
+
for (let col = 0; col < currentSheet.cols; col++) {
|
|
60
|
+
const key = `${getColName(col)}${row}`;
|
|
61
|
+
const raw = Object.hasOwn(currentSheet.cells, key) ? currentSheet.cells[key] : '';
|
|
62
|
+
if (typeof raw !== 'string' || raw.length > limits.maxCellLength) return false;
|
|
63
|
+
textLength += raw.length;
|
|
64
|
+
if (textLength > 1_000_000) return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
35
69
|
|
|
36
70
|
function getColName(index: number): string {
|
|
37
|
-
|
|
71
|
+
let result = '';
|
|
72
|
+
for (let i = index; i >= 0; i = Math.floor(i / 26) - 1) {
|
|
73
|
+
result = String.fromCharCode(i % 26 + 65) + result;
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
38
76
|
}
|
|
39
77
|
</script>
|
|
40
78
|
|
|
41
79
|
<div class="sv-lite-sheet-container {className}">
|
|
42
80
|
<div class="sv-lite-sheet-header">
|
|
43
|
-
<strong>
|
|
81
|
+
<strong>{i18n.t('spreadsheet.title')}: {currentSheet?.name ?? ''}</strong>
|
|
44
82
|
</div>
|
|
45
83
|
|
|
46
|
-
{#if currentSheet}
|
|
84
|
+
{#if !validWorkbook || (currentSheet && !canRender)}
|
|
85
|
+
<p role="alert">{i18n.t('spreadsheet.invalid')}</p>
|
|
86
|
+
{:else if !currentSheet}
|
|
87
|
+
<p role="status">{i18n.t('spreadsheet.empty')}</p>
|
|
88
|
+
{:else}
|
|
47
89
|
<form method="POST" action={formAction}>
|
|
90
|
+
<input type="hidden" name="sheetId" value={currentSheet.id} />
|
|
48
91
|
<table class="sv-lite-sheet-table">
|
|
49
92
|
<thead>
|
|
50
93
|
<tr>
|
|
@@ -64,8 +107,11 @@
|
|
|
64
107
|
<td class="sv-lite-sheet-td">
|
|
65
108
|
<input
|
|
66
109
|
type="text"
|
|
110
|
+
{readonly}
|
|
111
|
+
aria-label={key}
|
|
112
|
+
maxlength={limits.maxCellLength}
|
|
67
113
|
name={`cell_${key}`}
|
|
68
|
-
value={currentSheet.cells[key] ?? ''}
|
|
114
|
+
value={Object.hasOwn(currentSheet.cells, key) ? currentSheet.cells[key] ?? '' : ''}
|
|
69
115
|
class="sv-lite-cell-input"
|
|
70
116
|
/>
|
|
71
117
|
</td>
|
|
@@ -75,9 +121,9 @@
|
|
|
75
121
|
</tbody>
|
|
76
122
|
</table>
|
|
77
123
|
|
|
78
|
-
{#if formAction}
|
|
124
|
+
{#if formAction && !readonly}
|
|
79
125
|
<div class="sv-lite-sheet-footer">
|
|
80
|
-
<button type="submit" class="sv-lite-sheet-save">
|
|
126
|
+
<button type="submit" class="sv-lite-sheet-save">{i18n.t('common.save')}</button>
|
|
81
127
|
</div>
|
|
82
128
|
{/if}
|
|
83
129
|
</form>
|
|
@@ -9,6 +9,14 @@ interface Props {
|
|
|
9
9
|
sheets?: SheetData[];
|
|
10
10
|
activeSheetId?: string;
|
|
11
11
|
formAction?: string;
|
|
12
|
+
readonly?: boolean;
|
|
13
|
+
limits?: Partial<{
|
|
14
|
+
maxRows: number;
|
|
15
|
+
maxCols: number;
|
|
16
|
+
maxCells: number;
|
|
17
|
+
maxSheets: number;
|
|
18
|
+
maxCellLength: number;
|
|
19
|
+
}>;
|
|
12
20
|
class?: string;
|
|
13
21
|
}
|
|
14
22
|
declare const LiteSpreadsheetView: import("svelte").Component<Props, {}, "">;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 使用已有拓扑读取当前表单值,不把用户字段名解释成对象路径。服务端仍须独立授权。 */
|
|
2
|
+
export function readFilterForm(filters, fields, data) {
|
|
3
|
+
const operators = new Set(['eq', 'ne', 'contains', 'ncontains', 'gt', 'gte', 'lt', 'lte', 'null', 'nnull']);
|
|
4
|
+
function read(filter, path) {
|
|
5
|
+
const get = (key) => data.get(`filters[${path}][${key}]`);
|
|
6
|
+
if (!('field' in filter)) {
|
|
7
|
+
const operator = get('operator');
|
|
8
|
+
if (operator !== 'and' && operator !== 'or')
|
|
9
|
+
throw new TypeError('Invalid filter group');
|
|
10
|
+
return { operator, value: filter.value.map((child, index) => read(child, `${path}.value.${index}`)) };
|
|
11
|
+
}
|
|
12
|
+
const field = fields.find(candidate => candidate.key === get('field') && candidate.filterable !== false);
|
|
13
|
+
const operator = get('operator');
|
|
14
|
+
if (!field || typeof operator !== 'string' || !operators.has(operator))
|
|
15
|
+
throw new TypeError('Invalid filter rule');
|
|
16
|
+
let value = get('value');
|
|
17
|
+
if (operator === 'null' || operator === 'nnull')
|
|
18
|
+
value = null;
|
|
19
|
+
else if (typeof value !== 'string')
|
|
20
|
+
throw new TypeError('Invalid filter value');
|
|
21
|
+
else if (['number', 'currency', 'percent'].includes(field.type)) {
|
|
22
|
+
if (!value.trim() || !Number.isFinite(Number(value)))
|
|
23
|
+
throw new TypeError('Invalid numeric filter');
|
|
24
|
+
value = Number(value);
|
|
25
|
+
}
|
|
26
|
+
else if (field.type === 'boolean') {
|
|
27
|
+
if (value !== 'true' && value !== 'false')
|
|
28
|
+
throw new TypeError('Invalid boolean filter');
|
|
29
|
+
value = value === 'true';
|
|
30
|
+
}
|
|
31
|
+
return { field: field.key, operator: operator, value };
|
|
32
|
+
}
|
|
33
|
+
return filters.map((filter, index) => read(filter, String(index)));
|
|
34
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,13 @@ export { default as LiteTable } from './components/LiteTable.svelte';
|
|
|
9
9
|
export { default as LitePagination } from './components/LitePagination.svelte';
|
|
10
10
|
export { default as LiteForm } from './components/LiteForm.svelte';
|
|
11
11
|
export { default as LiteFileInput } from './components/LiteFileInput.svelte';
|
|
12
|
+
export { default as LiteFileUpload } from './components/LiteFileUpload.svelte';
|
|
12
13
|
export { default as LiteLogin } from './components/LiteLogin.svelte';
|
|
13
14
|
export { default as LiteShow } from './components/LiteShow.svelte';
|
|
14
15
|
export { default as LiteSearch } from './components/LiteSearch.svelte';
|
|
15
16
|
export { default as LiteAlert } from './components/LiteAlert.svelte';
|
|
17
|
+
export { default as LiteErrorSummary } from './components/LiteErrorSummary.svelte';
|
|
18
|
+
export type { LiteFormErrorEntry } from './components/LiteErrorSummary.svelte';
|
|
16
19
|
export { default as LitePermissionMatrix } from './components/LitePermissionMatrix.svelte';
|
|
17
20
|
export { default as LiteAuditLog } from './components/LiteAuditLog.svelte';
|
|
18
21
|
export { default as LiteArrayField } from './components/LiteArrayField.svelte';
|
|
@@ -56,6 +59,8 @@ export { default as LiteActivityFeed } from './components/LiteActivityFeed.svelt
|
|
|
56
59
|
export { default as LitePresenceAvatarGroup } from './components/LitePresenceAvatarGroup.svelte';
|
|
57
60
|
export { default as LitePrintableBill } from './components/LitePrintableBill.svelte';
|
|
58
61
|
export { default as LiteJsonSchemaForm } from './components/LiteJsonSchemaForm.svelte';
|
|
62
|
+
export { decodeSchemaFormArrayAction, decodeSchemaFormData, decodeSchemaFormSubmission, encodeSchemaFormArrayAction, schemaFormActionName, schemaFormFieldName, } from './schema-form-data';
|
|
63
|
+
export type { SchemaFormArrayAction, SchemaFormSchema } from './schema-form-data';
|
|
59
64
|
export { default as LiteMentionsInput } from './components/LiteMentionsInput.svelte';
|
|
60
65
|
export { default as LiteKanbanBoard } from './components/LiteKanbanBoard.svelte';
|
|
61
66
|
export { default as LitePivotTable } from './components/LitePivotTable.svelte';
|
package/dist/index.js
CHANGED
|
@@ -13,10 +13,12 @@ export { default as LiteTable } from './components/LiteTable.svelte';
|
|
|
13
13
|
export { default as LitePagination } from './components/LitePagination.svelte';
|
|
14
14
|
export { default as LiteForm } from './components/LiteForm.svelte';
|
|
15
15
|
export { default as LiteFileInput } from './components/LiteFileInput.svelte';
|
|
16
|
+
export { default as LiteFileUpload } from './components/LiteFileUpload.svelte';
|
|
16
17
|
export { default as LiteLogin } from './components/LiteLogin.svelte';
|
|
17
18
|
export { default as LiteShow } from './components/LiteShow.svelte';
|
|
18
19
|
export { default as LiteSearch } from './components/LiteSearch.svelte';
|
|
19
20
|
export { default as LiteAlert } from './components/LiteAlert.svelte';
|
|
21
|
+
export { default as LiteErrorSummary } from './components/LiteErrorSummary.svelte';
|
|
20
22
|
export { default as LitePermissionMatrix } from './components/LitePermissionMatrix.svelte';
|
|
21
23
|
export { default as LiteAuditLog } from './components/LiteAuditLog.svelte';
|
|
22
24
|
export { default as LiteArrayField } from './components/LiteArrayField.svelte';
|
|
@@ -56,6 +58,7 @@ export { default as LiteActivityFeed } from './components/LiteActivityFeed.svelt
|
|
|
56
58
|
export { default as LitePresenceAvatarGroup } from './components/LitePresenceAvatarGroup.svelte';
|
|
57
59
|
export { default as LitePrintableBill } from './components/LitePrintableBill.svelte';
|
|
58
60
|
export { default as LiteJsonSchemaForm } from './components/LiteJsonSchemaForm.svelte';
|
|
61
|
+
export { decodeSchemaFormArrayAction, decodeSchemaFormData, decodeSchemaFormSubmission, encodeSchemaFormArrayAction, schemaFormActionName, schemaFormFieldName, } from './schema-form-data';
|
|
59
62
|
export { default as LiteMentionsInput } from './components/LiteMentionsInput.svelte';
|
|
60
63
|
export { default as LiteKanbanBoard } from './components/LiteKanbanBoard.svelte';
|
|
61
64
|
export { default as LitePivotTable } from './components/LitePivotTable.svelte';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SchemaFormSchema } from '@svadmin/core/schema-form';
|
|
2
|
+
export type { SchemaFormSchema } from '@svadmin/core/schema-form';
|
|
3
|
+
export declare function schemaFormFieldName(path: readonly string[]): string;
|
|
4
|
+
export declare const schemaFormActionName = "schemaFormAction";
|
|
5
|
+
export type SchemaFormArrayAction = {
|
|
6
|
+
type: 'add' | 'remove';
|
|
7
|
+
path: string[];
|
|
8
|
+
index?: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function encodeSchemaFormArrayAction(action: SchemaFormArrayAction): string;
|
|
11
|
+
export declare function decodeSchemaFormArrayAction(form: FormData): SchemaFormArrayAction | undefined;
|
|
12
|
+
/** 按可信 Schema 解码并验证支持的约束;不替代资源授权和业务命令校验。 */
|
|
13
|
+
export declare function decodeSchemaFormData(schema: SchemaFormSchema, form: FormData): Record<string, unknown>;
|
|
14
|
+
/** 数组动作只生成下一份草稿;只有 submit 分支才可进入业务写入。 */
|
|
15
|
+
export declare function decodeSchemaFormSubmission(schema: SchemaFormSchema, form: FormData): {
|
|
16
|
+
kind: 'draft' | 'submit';
|
|
17
|
+
data: Record<string, unknown>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { assertSchemaFormSchema, isSchemaFormNodeVisible, prepareSchemaFormValue, validateSchemaFormValue } from '@svadmin/core/schema-form';
|
|
2
|
+
export function schemaFormFieldName(path) {
|
|
3
|
+
return `data:${JSON.stringify(path)}`;
|
|
4
|
+
}
|
|
5
|
+
export const schemaFormActionName = 'schemaFormAction';
|
|
6
|
+
export function encodeSchemaFormArrayAction(action) {
|
|
7
|
+
if (!['add', 'remove'].includes(action.type) ||
|
|
8
|
+
!Array.isArray(action.path) || action.path.length === 0 || action.path.length > 32 ||
|
|
9
|
+
!action.path.every(segment => typeof segment === 'string' && segment.length > 0 && segment.length <= 200)) {
|
|
10
|
+
throw new Error('Invalid schema form action.');
|
|
11
|
+
}
|
|
12
|
+
if (action['type'] === 'remove' &&
|
|
13
|
+
(!Number.isSafeInteger(action.index) || action.index < 0 || action.index > 1000)) {
|
|
14
|
+
throw new Error('Invalid schema form action.');
|
|
15
|
+
}
|
|
16
|
+
if (action['type'] === 'add' && action['index'] !== undefined)
|
|
17
|
+
throw new Error('Invalid schema form action.');
|
|
18
|
+
return JSON.stringify(action);
|
|
19
|
+
}
|
|
20
|
+
export function decodeSchemaFormArrayAction(form) {
|
|
21
|
+
const values = form.getAll(schemaFormActionName);
|
|
22
|
+
if (values.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
if (values.length !== 1 || typeof values[0] !== 'string' || values[0].length > 10000)
|
|
25
|
+
throw new Error('Invalid schema form action.');
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(values[0]);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error('Invalid schema form action.');
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
34
|
+
throw new Error('Invalid schema form action.');
|
|
35
|
+
const candidate = parsed;
|
|
36
|
+
if ((candidate['type'] !== 'add' && candidate['type'] !== 'remove') ||
|
|
37
|
+
!Array.isArray(candidate['path']) ||
|
|
38
|
+
!candidate['path'].every(segment => typeof segment === 'string' && segment.length > 0 && segment.length <= 200) ||
|
|
39
|
+
candidate['path'].length === 0 || candidate['path'].length > 32 ||
|
|
40
|
+
Object.keys(candidate).some(key => !['type', 'path', 'index'].includes(key))) {
|
|
41
|
+
throw new Error('Invalid schema form action.');
|
|
42
|
+
}
|
|
43
|
+
if (candidate['type'] === 'add' && Object.hasOwn(candidate, 'index'))
|
|
44
|
+
throw new Error('Invalid schema form action.');
|
|
45
|
+
if (candidate['type'] === 'remove' &&
|
|
46
|
+
(!Number.isSafeInteger(candidate['index']) || candidate['index'] < 0 || candidate['index'] > 1000)) {
|
|
47
|
+
throw new Error('Invalid schema form action.');
|
|
48
|
+
}
|
|
49
|
+
return candidate;
|
|
50
|
+
}
|
|
51
|
+
/** 按可信 Schema 解码并验证支持的约束;不替代资源授权和业务命令校验。 */
|
|
52
|
+
export function decodeSchemaFormData(schema, form) {
|
|
53
|
+
if (decodeSchemaFormArrayAction(form))
|
|
54
|
+
throw new Error('Array actions require decodeSchemaFormSubmission.');
|
|
55
|
+
return decodeFields(schema, form, true);
|
|
56
|
+
}
|
|
57
|
+
function decodeFields(schema, form, validate) {
|
|
58
|
+
assertSchemaFormSchema(schema);
|
|
59
|
+
decodeSchemaFormArrayAction(form);
|
|
60
|
+
const allowed = new Set();
|
|
61
|
+
const controllers = {};
|
|
62
|
+
const controllerValues = new Map();
|
|
63
|
+
let nodes = 0;
|
|
64
|
+
let textLength = 0;
|
|
65
|
+
const fail = () => { throw new Error('Invalid schema form data.'); };
|
|
66
|
+
function values(name) {
|
|
67
|
+
allowed.add(name);
|
|
68
|
+
return form.getAll(name).map(value => {
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
return fail();
|
|
71
|
+
textLength += value.length;
|
|
72
|
+
if (textLength > 1_000_000)
|
|
73
|
+
return fail();
|
|
74
|
+
return value;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function visit(node, path, depth) {
|
|
78
|
+
const cacheKey = JSON.stringify(path);
|
|
79
|
+
if (controllerValues.has(cacheKey))
|
|
80
|
+
return controllerValues.get(cacheKey);
|
|
81
|
+
if (++nodes > 1000 || depth > 32)
|
|
82
|
+
return fail();
|
|
83
|
+
if (node.type === 'object' || node.properties) {
|
|
84
|
+
const result = {};
|
|
85
|
+
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
86
|
+
if (!isSchemaFormNodeVisible(child, controllers))
|
|
87
|
+
continue;
|
|
88
|
+
Object.defineProperty(result, key, {
|
|
89
|
+
value: visit(child, [...path, key], depth + 1),
|
|
90
|
+
enumerable: true, configurable: true, writable: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
if (node.type === 'array') {
|
|
96
|
+
const counts = values(`length:${JSON.stringify(path)}`);
|
|
97
|
+
if (counts.length !== 1 || !/^(0|[1-9]\d*)$/.test(counts[0] ?? ''))
|
|
98
|
+
return fail();
|
|
99
|
+
const count = Number(counts[0]);
|
|
100
|
+
if (!Number.isSafeInteger(count) || count > 1000 - nodes)
|
|
101
|
+
return fail();
|
|
102
|
+
return Array.from({ length: count }, (_, index) => visit(node.items ?? { type: 'string' }, [...path, String(index)], depth + 1));
|
|
103
|
+
}
|
|
104
|
+
const entries = values(schemaFormFieldName(path));
|
|
105
|
+
if (node.type === 'boolean' && !node.enum) {
|
|
106
|
+
if (entries.length === 1 && entries[0] === 'false')
|
|
107
|
+
return false;
|
|
108
|
+
if (entries.length === 2 && entries[0] === 'false' && entries[1] === 'true')
|
|
109
|
+
return true;
|
|
110
|
+
return fail();
|
|
111
|
+
}
|
|
112
|
+
if (entries.length !== 1)
|
|
113
|
+
return fail();
|
|
114
|
+
const raw = entries[0] ?? '';
|
|
115
|
+
if (node.enum) {
|
|
116
|
+
if (raw === '')
|
|
117
|
+
return undefined;
|
|
118
|
+
if (!/^__json_enum_(0|[1-9]\d*)$/.test(raw))
|
|
119
|
+
return fail();
|
|
120
|
+
const index = Number(raw.slice('__json_enum_'.length));
|
|
121
|
+
if (!Number.isSafeInteger(index) || index >= node.enum.length)
|
|
122
|
+
return fail();
|
|
123
|
+
return node.enum[index];
|
|
124
|
+
}
|
|
125
|
+
if (node.type === 'null')
|
|
126
|
+
return raw === 'null' ? null : fail();
|
|
127
|
+
if (node.type === 'number' || node.type === 'integer') {
|
|
128
|
+
if (raw === '')
|
|
129
|
+
return undefined;
|
|
130
|
+
if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw))
|
|
131
|
+
return fail();
|
|
132
|
+
const number = Number(raw);
|
|
133
|
+
if (!Number.isFinite(number) || (node.type === 'integer' && !Number.isSafeInteger(number)))
|
|
134
|
+
return fail();
|
|
135
|
+
return number;
|
|
136
|
+
}
|
|
137
|
+
if (node.type !== undefined && node.type !== 'string')
|
|
138
|
+
return fail();
|
|
139
|
+
return raw;
|
|
140
|
+
}
|
|
141
|
+
if (schema.type !== 'object' && !schema.properties)
|
|
142
|
+
return fail();
|
|
143
|
+
// 控制字段只允许无条件标量,因此可先解码,不依赖属性声明顺序。
|
|
144
|
+
function collectControllers(node) {
|
|
145
|
+
const path = node.visibleWhen?.path;
|
|
146
|
+
if (path && !controllerValues.has(JSON.stringify(path))) {
|
|
147
|
+
let definition = schema;
|
|
148
|
+
for (const key of path) {
|
|
149
|
+
const next = definition.properties?.[key];
|
|
150
|
+
if (!next)
|
|
151
|
+
return fail();
|
|
152
|
+
definition = next;
|
|
153
|
+
}
|
|
154
|
+
const decoded = visit(definition, [...path], path.length);
|
|
155
|
+
controllerValues.set(JSON.stringify(path), decoded);
|
|
156
|
+
let target = controllers;
|
|
157
|
+
path.forEach((key, index) => {
|
|
158
|
+
if (index === path.length - 1) {
|
|
159
|
+
Object.defineProperty(target, key, { value: decoded, enumerable: true, configurable: true });
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
if (!Object.hasOwn(target, key))
|
|
163
|
+
Object.defineProperty(target, key, {
|
|
164
|
+
value: {}, enumerable: true, configurable: true,
|
|
165
|
+
});
|
|
166
|
+
target = target[key];
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
for (const child of Object.values(node.properties ?? {}))
|
|
171
|
+
collectControllers(child);
|
|
172
|
+
if (node.items)
|
|
173
|
+
collectControllers(node.items);
|
|
174
|
+
}
|
|
175
|
+
collectControllers(schema);
|
|
176
|
+
const result = visit(schema, [], 0);
|
|
177
|
+
if (validate && validateSchemaFormValue(schema, result).length > 0)
|
|
178
|
+
return fail();
|
|
179
|
+
for (const key of form.keys()) {
|
|
180
|
+
if ((key.startsWith('data:') || key.startsWith('length:')) && !allowed.has(key))
|
|
181
|
+
return fail();
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
/** 数组动作只生成下一份草稿;只有 submit 分支才可进入业务写入。 */
|
|
186
|
+
export function decodeSchemaFormSubmission(schema, form) {
|
|
187
|
+
const action = decodeSchemaFormArrayAction(form);
|
|
188
|
+
if (!action)
|
|
189
|
+
return { kind: 'submit', data: decodeSchemaFormData(schema, form) };
|
|
190
|
+
const data = decodeFields(schema, form, false);
|
|
191
|
+
const fail = () => { throw new Error('Invalid schema form action.'); };
|
|
192
|
+
let node = schema;
|
|
193
|
+
let current = data;
|
|
194
|
+
for (const segment of action.path) {
|
|
195
|
+
if (node.type === 'array') {
|
|
196
|
+
if (!/^(0|[1-9]\d*)$/.test(segment) || !Array.isArray(current) ||
|
|
197
|
+
Number(segment) >= current.length)
|
|
198
|
+
return fail();
|
|
199
|
+
node = node.items ?? { type: 'string' };
|
|
200
|
+
current = current[Number(segment)];
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
if (!node.properties || !Object.hasOwn(node.properties, segment) ||
|
|
204
|
+
current === null || typeof current !== 'object' || !Object.hasOwn(current, segment))
|
|
205
|
+
return fail();
|
|
206
|
+
const next = node.properties[segment];
|
|
207
|
+
if (!next)
|
|
208
|
+
return fail();
|
|
209
|
+
node = next;
|
|
210
|
+
current = current[segment];
|
|
211
|
+
}
|
|
212
|
+
if (!isSchemaFormNodeVisible(node, data))
|
|
213
|
+
return fail();
|
|
214
|
+
}
|
|
215
|
+
if (node.type !== 'array' || !Array.isArray(current))
|
|
216
|
+
return fail();
|
|
217
|
+
// 默认值也受结构预算约束,防止嵌套默认数组放大草稿。
|
|
218
|
+
let nodes = 0;
|
|
219
|
+
let length = 0;
|
|
220
|
+
function defaultValue(definition, depth) {
|
|
221
|
+
if (++nodes > 1000 || depth > 32)
|
|
222
|
+
return fail();
|
|
223
|
+
if (definition.default !== undefined)
|
|
224
|
+
return structuredClone(definition.default);
|
|
225
|
+
if (definition.type === 'object' || definition.properties) {
|
|
226
|
+
return Object.fromEntries(Object.entries(definition.properties ?? {})
|
|
227
|
+
.map(([key, child]) => [key, defaultValue(child, depth + 1)]));
|
|
228
|
+
}
|
|
229
|
+
if (definition.type === 'array')
|
|
230
|
+
return [];
|
|
231
|
+
if (definition.type === 'null')
|
|
232
|
+
return null;
|
|
233
|
+
if (definition.enum || definition.type === 'number' || definition.type === 'integer')
|
|
234
|
+
return undefined;
|
|
235
|
+
return definition.type === 'boolean' ? false : '';
|
|
236
|
+
}
|
|
237
|
+
if (action.type === 'add') {
|
|
238
|
+
if (current.length >= Math.min(node.maxItems ?? 1000, 1000))
|
|
239
|
+
return fail();
|
|
240
|
+
current.push(defaultValue(node.items ?? { type: 'string' }, action.path.length + 1));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
if (action.index === undefined || action.index >= current.length ||
|
|
244
|
+
current.length <= (node.minItems ?? 0))
|
|
245
|
+
return fail();
|
|
246
|
+
current.splice(action.index, 1);
|
|
247
|
+
}
|
|
248
|
+
nodes = 0;
|
|
249
|
+
function checkBudget(value, depth) {
|
|
250
|
+
if (++nodes > 1000 || depth > 32)
|
|
251
|
+
return fail();
|
|
252
|
+
if (typeof value === 'string')
|
|
253
|
+
length += value.length;
|
|
254
|
+
if (length > 1_000_000)
|
|
255
|
+
return fail();
|
|
256
|
+
if (value && typeof value === 'object') {
|
|
257
|
+
for (const child of Object.values(value))
|
|
258
|
+
checkBudget(child, depth + 1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
checkBudget(data, 0);
|
|
262
|
+
// 草稿允许尚未填写的必填项,但仍投影掉条件隐藏字段。
|
|
263
|
+
return { kind: 'draft', data: prepareSchemaFormValue(schema, data).data };
|
|
264
|
+
}
|