@svadmin/lite 0.13.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/dist/components/LiteErrorSummary.svelte +34 -0
- package/dist/components/LiteErrorSummary.svelte.d.ts +14 -0
- package/dist/components/LiteFilterBuilder.svelte +95 -36
- package/dist/components/LiteGanttChart.svelte +43 -10
- package/dist/components/LiteGanttChart.svelte.d.ts +6 -0
- package/dist/components/LiteJsonSchemaForm.svelte +90 -44
- package/dist/components/LiteJsonSchemaForm.svelte.d.ts +1 -10
- 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/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/schema-form-data.d.ts +18 -0
- package/dist/schema-form-data.js +264 -0
- package/dist/server-adapter.js +141 -26
- package/package.json +2 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
export interface LiteFormErrorEntry {
|
|
3
|
+
fieldKey: string;
|
|
4
|
+
label: string;
|
|
5
|
+
message: string;
|
|
6
|
+
/** 可选的表单控件 id;提供后错误项会链接到该控件。 */
|
|
7
|
+
fieldId?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
errors = [],
|
|
12
|
+
title = 'Please fix the following errors',
|
|
13
|
+
}: {
|
|
14
|
+
errors?: LiteFormErrorEntry[];
|
|
15
|
+
title?: string;
|
|
16
|
+
} = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
{#if errors.length > 0}
|
|
20
|
+
<section class="lite-error-summary" aria-labelledby="lite-error-summary-title">
|
|
21
|
+
<h2 id="lite-error-summary-title">{title}</h2>
|
|
22
|
+
<ul>
|
|
23
|
+
{#each errors as error (error.fieldKey)}
|
|
24
|
+
<li>
|
|
25
|
+
{#if error.fieldId}
|
|
26
|
+
<a href={`#${error.fieldId}`}>{error.label}: {error.message}</a>
|
|
27
|
+
{:else}
|
|
28
|
+
<span>{error.label}: {error.message}</span>
|
|
29
|
+
{/if}
|
|
30
|
+
</li>
|
|
31
|
+
{/each}
|
|
32
|
+
</ul>
|
|
33
|
+
</section>
|
|
34
|
+
{/if}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface LiteFormErrorEntry {
|
|
2
|
+
fieldKey: string;
|
|
3
|
+
label: string;
|
|
4
|
+
message: string;
|
|
5
|
+
/** 可选的表单控件 id;提供后错误项会链接到该控件。 */
|
|
6
|
+
fieldId?: string;
|
|
7
|
+
}
|
|
8
|
+
type $$ComponentProps = {
|
|
9
|
+
errors?: LiteFormErrorEntry[];
|
|
10
|
+
title?: string;
|
|
11
|
+
};
|
|
12
|
+
declare const LiteErrorSummary: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
13
|
+
type LiteErrorSummary = ReturnType<typeof LiteErrorSummary>;
|
|
14
|
+
export default LiteErrorSummary;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
|
|
3
|
-
import {
|
|
3
|
+
import { filterOperatorsForField as operatorsForField, isNumericFilterField as isNumericField,
|
|
4
|
+
isCollectionFilterOperator, isFilterCollectionValue } from '@svadmin/core';
|
|
4
5
|
import { t } from '@svadmin/core/i18n';
|
|
6
|
+
import { readFilterForm } from '../filter-form.js';
|
|
5
7
|
|
|
6
8
|
export interface FilterRuleItem {
|
|
7
9
|
id: string;
|
|
@@ -12,7 +14,7 @@
|
|
|
12
14
|
|
|
13
15
|
type FilterNode =
|
|
14
16
|
| { kind: 'group'; id: string; operator: 'and' | 'or'; children: FilterNode[] }
|
|
15
|
-
| { kind: 'rule'; id: string; field: string; operator: CrudOperator; value: unknown };
|
|
17
|
+
| { kind: 'rule'; id: string; field: string; operator: CrudOperator; value: unknown; readonly?: boolean };
|
|
16
18
|
|
|
17
19
|
interface Props {
|
|
18
20
|
fields?: FieldDefinition[];
|
|
@@ -31,41 +33,48 @@
|
|
|
31
33
|
disabled = false,
|
|
32
34
|
onApply,
|
|
33
35
|
}: Props = $props();
|
|
36
|
+
let validationError = $state<string | undefined>();
|
|
34
37
|
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
function handleSubmit(event: SubmitEvent): void {
|
|
39
|
-
if (disabled) { event.preventDefault(); return; }
|
|
40
|
-
// 无回调时保留原生 GET/POST;添加/删除规则仍交给原生服务端 action。
|
|
41
|
-
if (!onApply || (event.submitter instanceof HTMLButtonElement && event.submitter.name === '_action')) return;
|
|
42
|
-
event.preventDefault();
|
|
43
|
-
if (!(event.currentTarget instanceof HTMLFormElement)) return;
|
|
44
|
-
let next: Filter[];
|
|
45
|
-
try { next = readFilterForm(filters, availableFields, new FormData(event.currentTarget)); }
|
|
46
|
-
catch { formError = '筛选条件无效,请检查字段、运算符和值。'; return; }
|
|
47
|
-
formError = '';
|
|
48
|
-
onApply(next);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const operatorOptions: { value: CrudOperator; label: string }[] = [
|
|
38
|
+
const instanceId = $props.id();
|
|
39
|
+
const formId = `${instanceId}-filter-form`;
|
|
40
|
+
const operatorOptions = $derived<{ value: CrudOperator; label: string }[]>([
|
|
52
41
|
{ value: 'eq', label: '= 等于' },
|
|
53
42
|
{ value: 'ne', label: '!= 不等于' },
|
|
54
43
|
{ value: 'contains', label: '包含' },
|
|
55
44
|
{ value: 'ncontains', label: '不包含' },
|
|
45
|
+
{ value: 'startswith', label: '开头为' },
|
|
46
|
+
{ value: 'endswith', label: '结尾为' },
|
|
56
47
|
{ value: 'gt', label: '> 大于' },
|
|
57
48
|
{ value: 'gte', label: '>= 大于等于' },
|
|
58
49
|
{ value: 'lt', label: '< 小于' },
|
|
59
50
|
{ value: 'lte', label: '<= 小于等于' },
|
|
51
|
+
{ value: 'in', label: t('filter.in') },
|
|
52
|
+
{ value: 'nin', label: t('filter.nin') },
|
|
53
|
+
{ value: 'between', label: t('filter.between') },
|
|
54
|
+
{ value: 'nbetween', label: t('filter.nbetween') },
|
|
60
55
|
{ value: 'null', label: '为空' },
|
|
61
56
|
{ value: 'nnull', label: '不为空' },
|
|
62
|
-
];
|
|
57
|
+
]);
|
|
63
58
|
|
|
64
59
|
const availableFields = $derived(fields.filter(field => field.filterable !== false));
|
|
65
60
|
|
|
66
61
|
function nodeFromFilter(filter: Filter, path: string): FilterNode {
|
|
67
62
|
if ('field' in filter) {
|
|
68
|
-
|
|
63
|
+
const field = availableFields.find(candidate => candidate.key === filter.field);
|
|
64
|
+
const editable = field !== undefined
|
|
65
|
+
&& operatorsForField(field).includes(filter.operator)
|
|
66
|
+
&& (filter.operator === 'null' || filter.operator === 'nnull'
|
|
67
|
+
? filter.value === null
|
|
68
|
+
: isCollectionFilterOperator(filter.operator)
|
|
69
|
+
? isFilterCollectionValue(field, filter.operator, filter.value)
|
|
70
|
+
: isNumericField(field)
|
|
71
|
+
? typeof filter.value === 'number' && Number.isFinite(filter.value)
|
|
72
|
+
: field.type === 'boolean'
|
|
73
|
+
? typeof filter.value === 'boolean'
|
|
74
|
+
: field.type === 'select'
|
|
75
|
+
? field.options?.some(option => Object.is(option.value, filter.value)) === true
|
|
76
|
+
: typeof filter.value === 'string');
|
|
77
|
+
return { kind: 'rule', id: `rule-${path}`, field: filter.field, operator: filter.operator, value: filter.value, readonly: !editable };
|
|
69
78
|
}
|
|
70
79
|
return {
|
|
71
80
|
kind: 'group',
|
|
@@ -81,6 +90,19 @@
|
|
|
81
90
|
return node.kind === 'rule' ? node.value : undefined;
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
function handleSubmit(event: SubmitEvent): void {
|
|
94
|
+
if (!onApply) return;
|
|
95
|
+
const submitter = event.submitter;
|
|
96
|
+
if (submitter instanceof HTMLButtonElement && submitter.name === '_action') return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
try {
|
|
99
|
+
validationError = undefined;
|
|
100
|
+
onApply(readFilterForm(filters, fields, new FormData(event.currentTarget as HTMLFormElement)));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
validationError = error instanceof Error ? error.message : 'Invalid filters';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
</script>
|
|
85
107
|
|
|
86
108
|
<div class="lite-filter-builder lite-form-group">
|
|
@@ -91,8 +113,8 @@
|
|
|
91
113
|
</button>
|
|
92
114
|
</div>
|
|
93
115
|
|
|
94
|
-
{
|
|
95
|
-
|
|
116
|
+
<form id={formId} {action} {method} class="lite-filter-form" onsubmit={handleSubmit}>
|
|
117
|
+
{#if validationError}<p role="alert" class="lite-error">{validationError}</p>{/if}
|
|
96
118
|
{#snippet renderNode(node: FilterNode, path: string, isRoot = false)}
|
|
97
119
|
{#if node.kind === 'group'}
|
|
98
120
|
<fieldset class="lite-filter-group" data-testid={isRoot ? 'lite-filter-root' : 'lite-filter-group'}>
|
|
@@ -115,19 +137,54 @@
|
|
|
115
137
|
</fieldset>
|
|
116
138
|
{:else}
|
|
117
139
|
<div class="lite-filter-row" data-testid="lite-filter-rule">
|
|
118
|
-
|
|
119
|
-
{
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
{#if node.readonly}
|
|
141
|
+
<output aria-label={t('filter.readonly')}>{node.field} {node.operator} {JSON.stringify(node.value)}</output>
|
|
142
|
+
<!-- 严格解析器拒绝非协议属性,防止原生文本传输改变历史条件类型。 -->
|
|
143
|
+
<input type="hidden" name={`filters[${path}][invalid]`} value="true" {disabled} />
|
|
144
|
+
<input type="hidden" name={`filters[${path}][field]`} value={node.field} {disabled} />
|
|
145
|
+
<input type="hidden" name={`filters[${path}][operator]`} value={node.operator} {disabled} />
|
|
146
|
+
{#if node.operator !== 'null' && node.operator !== 'nnull'}
|
|
147
|
+
<input type="hidden" name={`filters[${path}][value]`} value={typeof node.value === 'string' ? node.value : JSON.stringify(node.value)} {disabled} />
|
|
148
|
+
{/if}
|
|
149
|
+
{:else}
|
|
150
|
+
<select name={`filters[${path}][field]`} value={node.field} {disabled}>
|
|
151
|
+
{#each availableFields as field (field.key)}<option value={field.key}>{field.label || field.key}</option>{/each}
|
|
152
|
+
</select>
|
|
153
|
+
<select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
|
|
154
|
+
{#each operatorOptions.filter(operator => operatorsForField(availableFields.find(field => field.key === node.field)).includes(operator.value)) as operator (operator.value)}<option value={operator.value}>{operator.label}</option>{/each}
|
|
155
|
+
</select>
|
|
156
|
+
{#if node.operator !== 'null' && node.operator !== 'nnull'}
|
|
157
|
+
{#if isCollectionFilterOperator(node.operator) && Array.isArray(node.value)}
|
|
158
|
+
{@const field = availableFields.find(field => field.key === node.field)}
|
|
159
|
+
<div class="lite-filter-values">
|
|
160
|
+
{#each node.value as item, index (index)}
|
|
161
|
+
{@const label = node.operator === 'between' || node.operator === 'nbetween'
|
|
162
|
+
? t(index === 0 ? 'filter.rangeStart' : 'filter.rangeEnd')
|
|
163
|
+
: t('filter.collectionItem', { number: String(index + 1) })}
|
|
164
|
+
{#if field?.type === 'select'}
|
|
165
|
+
<select aria-label={label} name={`filters[${path}][values.${index}]`} value={JSON.stringify(item)} {disabled}>
|
|
166
|
+
{#each field.options ?? [] as option, optionIndex (optionIndex)}
|
|
167
|
+
<option value={JSON.stringify(option.value)} disabled={option.disabled}>{option.label}</option>
|
|
168
|
+
{/each}
|
|
169
|
+
</select>
|
|
170
|
+
{:else if field?.type === 'boolean'}
|
|
171
|
+
<select aria-label={label} name={`filters[${path}][values.${index}]`} value={String(item)} {disabled}>
|
|
172
|
+
<option value="true">{t('common.yes')}</option>
|
|
173
|
+
<option value="false">{t('common.no')}</option>
|
|
174
|
+
</select>
|
|
175
|
+
{:else}
|
|
176
|
+
<input aria-label={label} name={`filters[${path}][values.${index}]`}
|
|
177
|
+
type={isNumericField(field) ? 'number' : 'text'} step="any" value={String(item)} {disabled} />
|
|
178
|
+
{/if}
|
|
179
|
+
{/each}
|
|
180
|
+
</div>
|
|
181
|
+
{:else if availableFields.find(field => field.key === node.field)?.type === 'boolean'}
|
|
182
|
+
<select name={`filters[${path}][value]`} value={String(readValue(node) ?? '')} {disabled}>
|
|
183
|
+
<option value="">请选择</option><option value="true">是</option><option value="false">否</option>
|
|
184
|
+
</select>
|
|
185
|
+
{:else}
|
|
186
|
+
<input name={`filters[${path}][value]`} type={isNumericField(availableFields.find(field => field.key === node.field)) ? 'number' : 'text'} value={String(readValue(node) ?? '')} {disabled} />
|
|
187
|
+
{/if}
|
|
131
188
|
{/if}
|
|
132
189
|
{/if}
|
|
133
190
|
<button type="submit" name="_action" value={`remove_filter_rule:${path}`} class="lite-btn lite-btn-sm lite-btn-danger" {disabled} title="删除此行条件">删除</button>
|
|
@@ -154,6 +211,8 @@
|
|
|
154
211
|
.lite-filter-row > * { min-width: 120px; flex: 1; }
|
|
155
212
|
.lite-filter-row > * + * { margin-left: 8px; }
|
|
156
213
|
.lite-filter-row .lite-btn { flex: 0 0 auto; }
|
|
214
|
+
.lite-filter-values { display: flex; flex-wrap: wrap; min-width: 0; }
|
|
215
|
+
.lite-filter-values > * { width: 160px; max-width: 100%; box-sizing: border-box; margin: 0 8px 8px 0; }
|
|
157
216
|
.lite-hint { color: #64748b; font-size: 12px; }
|
|
158
217
|
.lite-btn { margin-right: 6px; }
|
|
159
218
|
</style>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
3
|
+
import { GANTT_LIMITS, validateGantt } from '@svadmin/core/gantt';
|
|
2
4
|
export interface GanttTask {
|
|
3
5
|
id: string;
|
|
4
6
|
title: string;
|
|
@@ -6,12 +8,18 @@
|
|
|
6
8
|
durationDays: number;
|
|
7
9
|
progress?: number;
|
|
8
10
|
status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
|
11
|
+
dependencies?: string[];
|
|
12
|
+
milestone?: boolean;
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
interface Props {
|
|
12
16
|
tasks?: GanttTask[];
|
|
13
17
|
totalDays?: number;
|
|
14
18
|
dayLabelPrefix?: string;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
error?: string | undefined;
|
|
21
|
+
retryHref?: string;
|
|
22
|
+
ariaLabel?: string;
|
|
15
23
|
class?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
@@ -19,48 +27,73 @@
|
|
|
19
27
|
tasks = [],
|
|
20
28
|
totalDays = 14,
|
|
21
29
|
dayLabelPrefix = 'D',
|
|
30
|
+
loading = false,
|
|
31
|
+
error,
|
|
32
|
+
retryHref,
|
|
33
|
+
ariaLabel,
|
|
22
34
|
class: className = '',
|
|
23
35
|
}: Props = $props();
|
|
24
36
|
|
|
25
37
|
const days = $derived(
|
|
26
|
-
Array.from({ length: totalDays }, (_, i) => `${dayLabelPrefix}${i + 1}`)
|
|
38
|
+
Array.from({ length: Number.isSafeInteger(totalDays) && totalDays > 0 && totalDays <= GANTT_LIMITS.days ? totalDays : 0 }, (_, i) => `${dayLabelPrefix}${i + 1}`)
|
|
27
39
|
);
|
|
40
|
+
const i18n = useTranslation();
|
|
41
|
+
const validationError = $derived(validateGantt(loading || error ? [] : tasks, totalDays));
|
|
42
|
+
const displayError = $derived(error || (validationError ? i18n.t(`gantt.${validationError}`) : ''));
|
|
43
|
+
const safeRetryHref = $derived(retryHref?.startsWith('/') && !/^\/[\\/]/.test(retryHref)
|
|
44
|
+
&& !retryHref.includes('\\')
|
|
45
|
+
&& ![...retryHref].some(char => char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127) ? retryHref : undefined);
|
|
28
46
|
</script>
|
|
29
47
|
|
|
30
|
-
<div class="sv-lite-gantt-container {className}">
|
|
48
|
+
<div class="sv-lite-gantt-container {className}" aria-busy={loading}>
|
|
31
49
|
<div class="sv-lite-gantt-title">
|
|
32
|
-
<strong>
|
|
50
|
+
<strong>{i18n.t('gantt.title')}</strong> ({i18n.t('gantt.summary', { tasks: tasks.length, days: totalDays })})
|
|
33
51
|
</div>
|
|
34
52
|
|
|
35
|
-
|
|
53
|
+
{#if loading}
|
|
54
|
+
<div role="status">{i18n.t('common.loading')}</div>
|
|
55
|
+
{:else if displayError}
|
|
56
|
+
<div role="alert">{displayError}</div>
|
|
57
|
+
{#if error && safeRetryHref}<a href={safeRetryHref}>{i18n.t('common.retry')}</a>{/if}
|
|
58
|
+
{:else if tasks.length === 0}
|
|
59
|
+
<div role="status">{i18n.t('gantt.noData')}</div>
|
|
60
|
+
{:else}
|
|
61
|
+
<table class="sv-lite-gantt-table" aria-label={ariaLabel ?? i18n.t('gantt.title')}>
|
|
36
62
|
<thead>
|
|
37
63
|
<tr>
|
|
38
|
-
<th class="sv-lite-th-task">
|
|
39
|
-
<th class="sv-lite-th-status">
|
|
64
|
+
<th scope="col" class="sv-lite-th-task">{i18n.t('gantt.task')}</th>
|
|
65
|
+
<th scope="col" class="sv-lite-th-status">{i18n.t('gantt.status')}</th>
|
|
40
66
|
{#each days as day (day)}
|
|
41
|
-
<th class="sv-lite-th-day">{day}</th>
|
|
67
|
+
<th scope="col" class="sv-lite-th-day">{day}</th>
|
|
42
68
|
{/each}
|
|
43
69
|
</tr>
|
|
44
70
|
</thead>
|
|
45
71
|
<tbody>
|
|
46
72
|
{#each tasks as task (task.id)}
|
|
47
73
|
<tr>
|
|
48
|
-
<
|
|
74
|
+
<th scope="row" class="sv-lite-td-task" aria-label={`${task.title}, ${task.milestone
|
|
75
|
+
? i18n.t('gantt.milestone', { day: task.startDay + 1 })
|
|
76
|
+
: i18n.t('gantt.range', { start: task.startDay + 1, end: task.startDay + task.durationDays })}`}>{task.title}</th>
|
|
49
77
|
<td class="sv-lite-td-status">
|
|
50
|
-
<span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{task.status ?? 'planned'}</span>
|
|
78
|
+
<span class="sv-lite-gantt-badge sv-lite-{task.status ?? 'planned'}">{i18n.t(`gantt.${task.status ?? 'planned'}`)}</span>
|
|
51
79
|
</td>
|
|
52
80
|
{#each days as _, dIdx (dIdx)}
|
|
53
81
|
{@const isCovered = dIdx >= task.startDay && dIdx < task.startDay + task.durationDays}
|
|
54
|
-
|
|
82
|
+
{@const isMilestone = task.milestone && dIdx === task.startDay}
|
|
83
|
+
<td aria-label={isMilestone ? `${task.title}, ${i18n.t('gantt.milestone', { day: dIdx + 1 })}`
|
|
84
|
+
: isCovered ? `${task.title}, ${days[dIdx]}, ${i18n.t(`gantt.${task.status ?? 'planned'}`)}` : undefined}
|
|
85
|
+
class="sv-lite-td-cell {isCovered ? 'sv-lite-cell-active sv-lite-bg-' + (task.status ?? 'planned') : ''}">
|
|
55
86
|
{#if isCovered && dIdx === task.startDay && task.progress !== undefined}
|
|
56
87
|
<span class="sv-lite-cell-progress">{task.progress}%</span>
|
|
57
88
|
{/if}
|
|
89
|
+
{#if isMilestone}<span aria-hidden="true">◆</span>{/if}
|
|
58
90
|
</td>
|
|
59
91
|
{/each}
|
|
60
92
|
</tr>
|
|
61
93
|
{/each}
|
|
62
94
|
</tbody>
|
|
63
95
|
</table>
|
|
96
|
+
{/if}
|
|
64
97
|
</div>
|
|
65
98
|
|
|
66
99
|
<style>
|
|
@@ -5,11 +5,17 @@ export interface GanttTask {
|
|
|
5
5
|
durationDays: number;
|
|
6
6
|
progress?: number;
|
|
7
7
|
status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
|
|
8
|
+
dependencies?: string[];
|
|
9
|
+
milestone?: boolean;
|
|
8
10
|
}
|
|
9
11
|
interface Props {
|
|
10
12
|
tasks?: GanttTask[];
|
|
11
13
|
totalDays?: number;
|
|
12
14
|
dayLabelPrefix?: string;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
error?: string | undefined;
|
|
17
|
+
retryHref?: string;
|
|
18
|
+
ariaLabel?: string;
|
|
13
19
|
class?: string;
|
|
14
20
|
}
|
|
15
21
|
declare const LiteGanttChart: import("svelte").Component<Props, {}, "">;
|
|
@@ -1,14 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
description?: string;
|
|
6
|
-
properties?: Record<string, JsonSchema>;
|
|
7
|
-
items?: JsonSchema;
|
|
8
|
-
enum?: Array<string | number | boolean | null>;
|
|
9
|
-
default?: unknown;
|
|
10
|
-
required?: string[];
|
|
11
|
-
}
|
|
2
|
+
import { assertSchemaFormSchema, isSchemaFormNodeVisible, type SchemaFormSchema as JsonSchema } from '@svadmin/core/schema-form';
|
|
3
|
+
import { useTranslation } from '@svadmin/core/i18n';
|
|
4
|
+
import { encodeSchemaFormArrayAction, schemaFormActionName, schemaFormFieldName } from '../schema-form-data';
|
|
12
5
|
|
|
13
6
|
interface Props {
|
|
14
7
|
schema: JsonSchema;
|
|
@@ -20,14 +13,17 @@
|
|
|
20
13
|
|
|
21
14
|
let {
|
|
22
15
|
schema,
|
|
23
|
-
value = $bindable(
|
|
16
|
+
value = $bindable(undefined),
|
|
24
17
|
action = '',
|
|
25
18
|
method = 'POST',
|
|
26
19
|
submitText = 'Submit Form',
|
|
27
20
|
}: Props = $props();
|
|
21
|
+
const instanceId = $props.id();
|
|
22
|
+
const i18n = useTranslation();
|
|
28
23
|
|
|
29
24
|
function cloneDefault(node: JsonSchema): unknown {
|
|
30
|
-
if (node.default !== undefined) return structuredClone(node.default);
|
|
25
|
+
if (node.default !== undefined) return structuredClone($state.snapshot(node.default));
|
|
26
|
+
if (node.type === 'null') return null;
|
|
31
27
|
if (node.type === 'object' || node.properties) {
|
|
32
28
|
return Object.fromEntries(
|
|
33
29
|
Object.entries(node.properties ?? {})
|
|
@@ -39,55 +35,82 @@
|
|
|
39
35
|
return undefined;
|
|
40
36
|
}
|
|
41
37
|
|
|
42
|
-
function mergeDefaults(node: JsonSchema, current: unknown): unknown {
|
|
38
|
+
function mergeDefaults(node: JsonSchema, current: unknown, present: boolean, budget = { nodes: 0 }): unknown {
|
|
39
|
+
if (++budget.nodes > 1000) throw new Error('Schema form data limit exceeded.');
|
|
40
|
+
const candidate = present ? current : cloneDefault(node);
|
|
41
|
+
if (candidate === null || (present && candidate === undefined)) return candidate;
|
|
43
42
|
if (node.type === 'array') {
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
43
|
+
if (Array.isArray(candidate) && candidate.length > 1000 - budget.nodes) throw new Error('Schema form data limit exceeded.');
|
|
44
|
+
return Array.isArray(candidate)
|
|
45
|
+
? candidate.map(item => mergeDefaults(node.items ?? { type: 'string' }, item, true, budget))
|
|
46
|
+
: candidate;
|
|
46
47
|
}
|
|
47
|
-
if (node.type !== 'object' && !node.properties) return
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: {};
|
|
48
|
+
if (node.type !== 'object' && !node.properties) return candidate;
|
|
49
|
+
if (candidate !== undefined && (typeof candidate !== 'object' || Array.isArray(candidate))) return candidate;
|
|
50
|
+
const source = (candidate ?? {}) as Record<string, unknown>;
|
|
51
51
|
const result: Record<string, unknown> = { ...source };
|
|
52
52
|
for (const [key, child] of Object.entries(node.properties ?? {})) {
|
|
53
|
-
const
|
|
54
|
-
|
|
53
|
+
const own = Object.hasOwn(source, key);
|
|
54
|
+
const next = mergeDefaults(child, own ? source[key] : undefined, own, budget);
|
|
55
|
+
if (next !== undefined || own) Object.defineProperty(result, key, { value: next, enumerable: true, configurable: true, writable: true });
|
|
55
56
|
}
|
|
56
57
|
return result;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
$
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
const resolved = $derived.by(() => {
|
|
61
|
+
try {
|
|
62
|
+
assertSchemaFormSchema(schema);
|
|
63
|
+
const next = mergeDefaults(schema, value, value !== undefined);
|
|
64
|
+
return { invalid: false, data: next && typeof next === 'object' && !Array.isArray(next) ? next as Record<string, unknown> : {} };
|
|
65
|
+
} catch {
|
|
66
|
+
return { invalid: true, data: {} as Record<string, unknown> };
|
|
67
|
+
}
|
|
62
68
|
});
|
|
69
|
+
const resolvedValue = $derived(resolved.data);
|
|
63
70
|
|
|
64
71
|
function readPath(path: string[]): unknown {
|
|
65
72
|
return path.reduce<unknown>((current, key) => (
|
|
66
|
-
current && typeof current === 'object' ? (current as Record<string, unknown>)[key] : undefined
|
|
67
|
-
),
|
|
73
|
+
current && typeof current === 'object' && Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined
|
|
74
|
+
), resolvedValue);
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
function writePath(path: string[], nextValue: unknown): void {
|
|
71
|
-
const next = structuredClone(
|
|
78
|
+
const next = structuredClone($state.snapshot(resolvedValue));
|
|
72
79
|
let target: Record<string, unknown> = next;
|
|
73
80
|
for (const key of path.slice(0, -1)) {
|
|
74
|
-
const child = target[key];
|
|
75
|
-
|
|
76
|
-
target
|
|
81
|
+
const child = Object.hasOwn(target, key) ? target[key] : undefined;
|
|
82
|
+
const container = child && typeof child === 'object' ? child : {};
|
|
83
|
+
Object.defineProperty(target, key, { value: container, enumerable: true, configurable: true, writable: true });
|
|
84
|
+
target = container as Record<string, unknown>;
|
|
77
85
|
}
|
|
78
86
|
const lastKey = path[path.length - 1];
|
|
79
|
-
if (lastKey !== undefined) target
|
|
87
|
+
if (lastKey !== undefined) Object.defineProperty(target, lastKey, { value: nextValue, enumerable: true, configurable: true, writable: true });
|
|
80
88
|
value = next;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
function fieldName(path: string[]): string {
|
|
92
|
+
return schemaFormFieldName(path);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function enumToken(index: number): string {
|
|
96
|
+
return `__json_enum_${index}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function enumIndex(current: unknown, options: JsonSchema['enum']): number {
|
|
100
|
+
return options?.findIndex(option => Object.is(option, current)) ?? -1;
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
function enumValue(raw: string, options: JsonSchema['enum']): unknown {
|
|
84
|
-
|
|
104
|
+
if (!/^__json_enum_(0|[1-9]\d*)$/.test(raw)) return undefined;
|
|
105
|
+
const index = Number(raw.slice('__json_enum_'.length));
|
|
106
|
+
return Number.isSafeInteger(index) ? options?.[index] : undefined;
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
function addArrayItem(path: string[], itemSchema: JsonSchema): void {
|
|
88
110
|
const current = readPath(path);
|
|
89
111
|
const items = Array.isArray(current) ? current : [];
|
|
90
|
-
|
|
112
|
+
const item = cloneDefault(itemSchema);
|
|
113
|
+
writePath(path, [...items, item === undefined ? (itemSchema.type === 'object' ? {} : '') : item]);
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
function removeArrayItem(path: string[], index: number): void {
|
|
@@ -101,10 +124,13 @@
|
|
|
101
124
|
{#if schema.description}<p class="lite-schema-description">{schema.description}</p>{/if}
|
|
102
125
|
|
|
103
126
|
<form {action} {method} class="lite-schema-form">
|
|
127
|
+
{#if resolved.invalid}<p role="alert">{i18n.t('common.operationFailed')}</p>{/if}
|
|
104
128
|
{#snippet renderField(node: JsonSchema, path: string[], title: string, required = false)}
|
|
105
129
|
{@const current = readPath(path)}
|
|
106
|
-
{@const id =
|
|
107
|
-
{#if node
|
|
130
|
+
{@const id = `${instanceId}-${encodeURIComponent(JSON.stringify(path))}`}
|
|
131
|
+
{#if !isSchemaFormNodeVisible(node, resolvedValue)}
|
|
132
|
+
<!-- 隐藏字段不进入原生表单传输。 -->
|
|
133
|
+
{:else if node.type === 'object' || node.properties}
|
|
108
134
|
<fieldset class="lite-schema-fieldset">
|
|
109
135
|
<legend>{title}</legend>
|
|
110
136
|
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
@@ -115,41 +141,61 @@
|
|
|
115
141
|
{:else if node.type === 'array'}
|
|
116
142
|
{@const items = Array.isArray(current) ? current : []}
|
|
117
143
|
<fieldset class="lite-schema-fieldset">
|
|
144
|
+
<input type="hidden" name={`length:${JSON.stringify(path)}`} value={items.length} />
|
|
118
145
|
<legend>{title}</legend>
|
|
119
146
|
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
120
147
|
{#each items as _, index (index)}
|
|
121
148
|
{@render renderField(node.items ?? { type: 'string' }, [...path, String(index)], `${title} ${index + 1}`)}
|
|
122
|
-
<button
|
|
149
|
+
<button
|
|
150
|
+
type={action ? 'submit' : 'button'}
|
|
151
|
+
formnovalidate
|
|
152
|
+
name={action ? schemaFormActionName : undefined}
|
|
153
|
+
value={action ? encodeSchemaFormArrayAction({ type: 'remove', path, index }) : undefined}
|
|
154
|
+
class="lite-btn lite-btn-sm"
|
|
155
|
+
disabled={node.minItems !== undefined && items.length <= node.minItems}
|
|
156
|
+
onclick={action ? (event) => { event.preventDefault(); removeArrayItem(path, index); } : () => removeArrayItem(path, index)}
|
|
157
|
+
>Remove</button>
|
|
123
158
|
{:else}
|
|
124
159
|
<span class="lite-hint">No items</span>
|
|
125
160
|
{/each}
|
|
126
|
-
<button
|
|
161
|
+
<button
|
|
162
|
+
type={action ? 'submit' : 'button'}
|
|
163
|
+
formnovalidate
|
|
164
|
+
name={action ? schemaFormActionName : undefined}
|
|
165
|
+
value={action ? encodeSchemaFormArrayAction({ type: 'add', path }) : undefined}
|
|
166
|
+
class="lite-btn lite-btn-sm"
|
|
167
|
+
disabled={node.maxItems !== undefined && items.length >= node.maxItems}
|
|
168
|
+
onclick={action ? (event) => { event.preventDefault(); addArrayItem(path, node.items ?? { type: 'string' }); } : () => addArrayItem(path, node.items ?? { type: 'string' })}
|
|
169
|
+
>Add item</button>
|
|
127
170
|
</fieldset>
|
|
128
171
|
{:else}
|
|
129
172
|
<div class="lite-schema-field">
|
|
130
173
|
<label for={id} class="lite-label">{title}{#if required}<span aria-hidden="true">*</span>{/if}</label>
|
|
131
174
|
{#if node.description}<p class="lite-schema-description">{node.description}</p>{/if}
|
|
132
175
|
{#if node.enum}
|
|
133
|
-
<select id={id} required={required} value={
|
|
176
|
+
<select id={id} name={fieldName(path)} required={required} value={enumIndex(current, node.enum) < 0 ? '' : enumToken(enumIndex(current, node.enum))} onchange={(event) => writePath(path, enumValue(event.currentTarget.value, node.enum))} class="lite-select">
|
|
134
177
|
<option value="">Select an option</option>
|
|
135
|
-
{#each node.enum as option (
|
|
178
|
+
{#each node.enum as option, index (index)}<option value={enumToken(index)}>{String(option)}</option>{/each}
|
|
136
179
|
</select>
|
|
180
|
+
{:else if node.type === 'null'}
|
|
181
|
+
<input id={id} name={fieldName(path)} value="null" readonly />
|
|
137
182
|
{:else if node.type === 'boolean'}
|
|
138
|
-
<input
|
|
183
|
+
<input type="hidden" name={fieldName(path)} value="false" />
|
|
184
|
+
<input id={id} type="checkbox" name={fieldName(path)} value="true" checked={Boolean(current)} onchange={(event) => writePath(path, event.currentTarget.checked)} />
|
|
139
185
|
{:else if node.type === 'number' || node.type === 'integer'}
|
|
140
|
-
<input id={id} type="number" required={required} value={current
|
|
186
|
+
<input id={id} name={fieldName(path)} type="number" step={node.type === 'integer' ? 1 : 'any'} min={node.minimum} max={node.maximum} required={required} value={current == null ? '' : String(current)} oninput={(event) => writePath(path, event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} class="lite-input" />
|
|
141
187
|
{:else}
|
|
142
|
-
<input id={id} type="text" required={required} value={String(current ?? '')} oninput={(event) => writePath(path, event.currentTarget.value)} class="lite-input" />
|
|
188
|
+
<input id={id} name={fieldName(path)} type="text" minlength={node.minLength} maxlength={node.maxLength} pattern={node.pattern} required={required} value={String(current ?? '')} oninput={(event) => writePath(path, event.currentTarget.value)} class="lite-input" />
|
|
143
189
|
{/if}
|
|
144
190
|
</div>
|
|
145
191
|
{/if}
|
|
146
192
|
{/snippet}
|
|
147
193
|
|
|
148
|
-
{#each Object.entries(schema.properties ?? {}) as [key, node] (key)}
|
|
194
|
+
{#each Object.entries(resolved.invalid ? {} : schema.properties ?? {}) as [key, node] (key)}
|
|
149
195
|
{@render renderField(node, [key], node.title ?? key, schema.required?.includes(key) ?? false)}
|
|
150
196
|
{/each}
|
|
151
197
|
<div class="lite-schema-footer">
|
|
152
|
-
<button type="submit" class="lite-btn lite-btn-primary lite-btn-sm">{submitText}</button>
|
|
198
|
+
<button type="submit" disabled={resolved.invalid} class="lite-btn lite-btn-primary lite-btn-sm">{submitText}</button>
|
|
153
199
|
</div>
|
|
154
200
|
</form>
|
|
155
201
|
</div>
|
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
type?: string;
|
|
3
|
-
title?: string;
|
|
4
|
-
description?: string;
|
|
5
|
-
properties?: Record<string, JsonSchema>;
|
|
6
|
-
items?: JsonSchema;
|
|
7
|
-
enum?: Array<string | number | boolean | null>;
|
|
8
|
-
default?: unknown;
|
|
9
|
-
required?: string[];
|
|
10
|
-
}
|
|
1
|
+
import { type SchemaFormSchema as JsonSchema } from '@svadmin/core/schema-form';
|
|
11
2
|
interface Props {
|
|
12
3
|
schema: JsonSchema;
|
|
13
4
|
value?: Record<string, unknown>;
|