@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.
@@ -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 { readFilterForm } from '../filter-form.js';
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 formId = $props.id();
36
- let formError = $state('');
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
- return { kind: 'rule', id: `rule-${path}`, field: filter.field, operator: filter.operator, value: filter.value };
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
- {#if formError}<p role="alert">{formError}</p>{/if}
95
- <form id={formId} {action} {method} onsubmit={handleSubmit} class="lite-filter-form">
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
- <select name={`filters[${path}][field]`} value={node.field} {disabled}>
119
- {#each availableFields as field (field.key)}<option value={field.key}>{field.label || field.key}</option>{/each}
120
- </select>
121
- <select name={`filters[${path}][operator]`} value={node.operator} {disabled}>
122
- {#each operatorOptions as operator (operator.value)}<option value={operator.value}>{operator.label}</option>{/each}
123
- </select>
124
- {#if node.operator !== 'null' && node.operator !== 'nnull'}
125
- {#if availableFields.find(field => field.key === node.field)?.type === 'boolean'}
126
- <select name={`filters[${path}][value]`} value={String(readValue(node) ?? '')} {disabled}>
127
- <option value="">请选择</option><option value="true">是</option><option value="false">否</option>
128
- </select>
129
- {:else}
130
- <input name={`filters[${path}][value]`} type={availableFields.find(field => field.key === node.field)?.type === 'number' ? 'number' : 'text'} value={String(readValue(node) ?? '')} {disabled} />
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>Project Schedule</strong> ({tasks.length} tasks / {totalDays} days)
50
+ <strong>{i18n.t('gantt.title')}</strong> ({i18n.t('gantt.summary', { tasks: tasks.length, days: totalDays })})
33
51
  </div>
34
52
 
35
- <table class="sv-lite-gantt-table">
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">Task</th>
39
- <th class="sv-lite-th-status">Status</th>
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
- <td class="sv-lite-td-task">{task.title}</td>
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
- <td class="sv-lite-td-cell {isCovered ? 'sv-lite-cell-active sv-lite-bg-' + (task.status ?? 'planned') : ''}">
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
- interface JsonSchema {
3
- type?: string;
4
- title?: string;
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 (!Array.isArray(current)) return cloneDefault(node);
45
- return current.map(item => node.items ? mergeDefaults(node.items, item) : item);
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 current ?? cloneDefault(node);
48
- const source = current && typeof current === 'object' && !Array.isArray(current)
49
- ? current as Record<string, unknown>
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 next = mergeDefaults(child, result[key]);
54
- if (next !== undefined) result[key] = next;
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
- $effect(() => {
60
- const next = mergeDefaults(schema, value);
61
- if (JSON.stringify(next) !== JSON.stringify(value)) value = next as Record<string, unknown>;
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
- ), value);
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(value);
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
- target[key] = child && typeof child === 'object' ? child : {};
76
- target = target[key] as Record<string, unknown>;
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[lastKey] = nextValue;
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
- return options?.find(option => String(option) === raw) ?? raw;
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
- writePath(path, [...items, cloneDefault(itemSchema) ?? (itemSchema.type === 'object' ? {} : '')]);
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 = `lite_json_${path.join('_')}`}
107
- {#if node.type === 'object' || node.properties}
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 type="button" class="lite-btn lite-btn-sm" onclick={() => removeArrayItem(path, index)}>Remove</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 type="button" class="lite-btn lite-btn-sm" onclick={() => addArrayItem(path, node.items ?? { type: 'string' })}>Add item</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={String(current ?? '')} onchange={(event) => writePath(path, enumValue(event.currentTarget.value, node.enum))} class="lite-select">
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 (String(option))}<option value={String(option)}>{String(option)}</option>{/each}
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 id={id} type="checkbox" checked={Boolean(current)} onchange={(event) => writePath(path, event.currentTarget.checked)} />
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 === undefined ? '' : String(current)} oninput={(event) => writePath(path, event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} class="lite-input" />
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
- interface JsonSchema {
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>;