@svadmin/ui 0.88.1 → 0.92.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.
Files changed (172) hide show
  1. package/dist/app.css +4 -0
  2. package/dist/app.theme.css +4 -0
  3. package/dist/component-registry.svelte.d.ts +2 -2
  4. package/dist/components/AboutSettings.svelte +23 -4
  5. package/dist/components/AdminApp.svelte +6 -4
  6. package/dist/components/AdminApp.svelte.d.ts +3 -3
  7. package/dist/components/ApiSettings.svelte +43 -27
  8. package/dist/components/ApprovalActionCard.svelte +8 -9
  9. package/dist/components/ApprovalCenter.svelte +24 -5
  10. package/dist/components/AuditLogViewer.svelte +2 -2
  11. package/dist/components/AutoComplete.svelte +610 -0
  12. package/dist/components/AutoComplete.svelte.d.ts +25 -0
  13. package/dist/components/AutoForm.svelte +265 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +141 -36
  16. package/dist/components/AutoTable.svelte.d.ts +8 -0
  17. package/dist/components/BatchActionBar.svelte +42 -14
  18. package/dist/components/BatchActionBar.svelte.d.ts +5 -0
  19. package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
  20. package/dist/components/CancelTaskButton.svelte +14 -1
  21. package/dist/components/Cascader.svelte +412 -53
  22. package/dist/components/Cascader.svelte.d.ts +7 -1
  23. package/dist/components/ColorPicker.svelte +1 -1
  24. package/dist/components/ColorPicker.svelte.d.ts +1 -1
  25. package/dist/components/ComboboxField.svelte +270 -34
  26. package/dist/components/ComboboxField.svelte.d.ts +4 -0
  27. package/dist/components/CommandPalette.svelte +4 -4
  28. package/dist/components/DashboardView.svelte +73 -7
  29. package/dist/components/DashboardView.svelte.d.ts +3 -1
  30. package/dist/components/DateRangeInput.svelte +8 -1
  31. package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
  32. package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
  33. package/dist/components/DetailWorkspaceSections.svelte +233 -0
  34. package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
  35. package/dist/components/DrawerForm.svelte +78 -9
  36. package/dist/components/DrawerForm.svelte.d.ts +3 -3
  37. package/dist/components/ErrorBoundary.svelte +1 -1
  38. package/dist/components/ErrorComponent.svelte +19 -6
  39. package/dist/components/ErrorComponent.svelte.d.ts +1 -0
  40. package/dist/components/ErrorSummary.svelte +9 -4
  41. package/dist/components/FieldRenderer.svelte +203 -90
  42. package/dist/components/FieldRenderer.svelte.d.ts +16 -1
  43. package/dist/components/FileUpload.svelte +116 -51
  44. package/dist/components/FileUpload.svelte.d.ts +7 -27
  45. package/dist/components/FileUploadField.svelte +170 -0
  46. package/dist/components/FileUploadField.svelte.d.ts +21 -0
  47. package/dist/components/FilterBuilder.svelte +13 -7
  48. package/dist/components/FilterCollectionInput.svelte +9 -7
  49. package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
  50. package/dist/components/ForgotPasswordPage.svelte +1 -1
  51. package/dist/components/Header.svelte +1 -1
  52. package/dist/components/ImportWizard.svelte +197 -28
  53. package/dist/components/ImportWizard.svelte.d.ts +5 -1
  54. package/dist/components/InferencerPanel.svelte +4 -2
  55. package/dist/components/InfiniteList.svelte +52 -9
  56. package/dist/components/JsonSchemaForm.svelte +237 -42
  57. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
  58. package/dist/components/KanbanBoard.svelte +43 -7
  59. package/dist/components/KanbanBoard.svelte.d.ts +2 -2
  60. package/dist/components/Layout.svelte +1 -1
  61. package/dist/components/LoginPage.svelte +1 -1
  62. package/dist/components/MetricStrip.svelte +8 -3
  63. package/dist/components/MetricStrip.svelte.d.ts +1 -0
  64. package/dist/components/ModalForm.svelte +85 -9
  65. package/dist/components/ModalForm.svelte.d.ts +3 -3
  66. package/dist/components/MoneyInput.svelte +5 -3
  67. package/dist/components/MoneyInput.svelte.d.ts +4 -2
  68. package/dist/components/MultiTabKeepAlive.svelte +502 -43
  69. package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
  70. package/dist/components/NotificationsSettings.svelte +10 -10
  71. package/dist/components/NumberInput.svelte +5 -3
  72. package/dist/components/NumberInput.svelte.d.ts +4 -2
  73. package/dist/components/NumericInput.svelte +277 -25
  74. package/dist/components/NumericInput.svelte.d.ts +3 -1
  75. package/dist/components/PercentInput.svelte +5 -3
  76. package/dist/components/PercentInput.svelte.d.ts +4 -2
  77. package/dist/components/PermissionMatrix.svelte +44 -8
  78. package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
  79. package/dist/components/ProfilePage.svelte +38 -13
  80. package/dist/components/RadioGroup.svelte +203 -4
  81. package/dist/components/RadioGroup.svelte.d.ts +1 -1
  82. package/dist/components/Rate.svelte +3 -3
  83. package/dist/components/Rate.svelte.d.ts +2 -2
  84. package/dist/components/RecordDetailDrawer.svelte +1 -1
  85. package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
  86. package/dist/components/RegisterPage.svelte +1 -1
  87. package/dist/components/RelationPicker.svelte +39 -11
  88. package/dist/components/ResourceAccessGuard.svelte +7 -2
  89. package/dist/components/RetryTaskButton.svelte +14 -1
  90. package/dist/components/RolesSettings.svelte +74 -28
  91. package/dist/components/SecuritySettings.svelte +66 -51
  92. package/dist/components/SegmentedControl.svelte +4 -2
  93. package/dist/components/SegmentedControl.svelte.d.ts +1 -1
  94. package/dist/components/Sidebar.svelte +1 -1
  95. package/dist/components/StepsForm.svelte +62 -10
  96. package/dist/components/StepsForm.svelte.d.ts +4 -2
  97. package/dist/components/SvadminLogo.svelte +1 -0
  98. package/dist/components/SvarDataGrid.svelte +42 -24
  99. package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
  100. package/dist/components/SvarResourceRead.svelte +2 -0
  101. package/dist/components/SvarResourceTableView.svelte +4 -2
  102. package/dist/components/TagsInput.svelte +3 -3
  103. package/dist/components/TagsInput.svelte.d.ts +2 -2
  104. package/dist/components/TaskDetails.svelte +22 -16
  105. package/dist/components/TaskDetails.svelte.d.ts +1 -1
  106. package/dist/components/TaskList.svelte +57 -15
  107. package/dist/components/TaskQueueDrawer.svelte +54 -34
  108. package/dist/components/Toast.svelte +13 -2
  109. package/dist/components/Transfer.svelte +67 -5
  110. package/dist/components/Transfer.svelte.d.ts +4 -0
  111. package/dist/components/TreeSelect.svelte +337 -77
  112. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  113. package/dist/components/TreeTable.svelte +6 -1
  114. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  115. package/dist/components/UpdatePasswordPage.svelte +1 -1
  116. package/dist/components/VersionDiffViewer.svelte +9 -7
  117. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  118. package/dist/components/account/MemberDirectory.svelte +14 -10
  119. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  120. package/dist/components/content/AuditTimeline.svelte +8 -6
  121. package/dist/components/content/DataState.svelte +1 -1
  122. package/dist/components/content/FileList.svelte +44 -13
  123. package/dist/components/content/FileList.svelte.d.ts +4 -2
  124. package/dist/components/content/FileListRow.svelte +87 -0
  125. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  126. package/dist/components/content/ImportDropzone.svelte +109 -12
  127. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  128. package/dist/components/content/IntegrationCard.svelte +3 -3
  129. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  130. package/dist/components/content/MediaThumbnail.svelte +6 -2
  131. package/dist/components/detail-workspace.d.ts +60 -0
  132. package/dist/components/detail-workspace.js +3 -0
  133. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  134. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  135. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  136. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  137. package/dist/components/enterprise/schema-form-references.js +316 -0
  138. package/dist/components/fields/ArrayField.svelte +178 -14
  139. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  140. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  141. package/dist/components/fields/array-field-metadata.js +36 -0
  142. package/dist/components/file-upload-reference.d.ts +11 -0
  143. package/dist/components/file-upload-reference.js +50 -0
  144. package/dist/components/file-upload-types.d.ts +32 -0
  145. package/dist/components/file-upload-types.js +1 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  147. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  148. package/dist/components/json-schema-form-state.d.ts +19 -0
  149. package/dist/components/list-deletion.svelte.js +6 -1
  150. package/dist/components/list-state.svelte.d.ts +12 -1
  151. package/dist/components/list-state.svelte.js +56 -16
  152. package/dist/components/numeric-format.d.ts +44 -0
  153. package/dist/components/numeric-format.js +198 -0
  154. package/dist/components/record-detail.svelte.js +2 -0
  155. package/dist/components/saved-list-views.d.ts +9 -2
  156. package/dist/components/saved-list-views.js +42 -11
  157. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  158. package/dist/components/saved-list-views.svelte.js +56 -7
  159. package/dist/components/task-action.svelte.d.ts +1 -0
  160. package/dist/components/task-action.svelte.js +55 -3
  161. package/dist/components/task-utils.d.ts +1 -0
  162. package/dist/components/task-utils.js +18 -0
  163. package/dist/components/ui/input/input.svelte +1 -1
  164. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  165. package/dist/components/ui/sheet/sheet.svelte +4 -1
  166. package/dist/components/version-history.d.ts +14 -0
  167. package/dist/components/version-history.js +32 -0
  168. package/dist/components/workspace-tab-state.d.ts +78 -0
  169. package/dist/components/workspace-tab-state.js +587 -0
  170. package/dist/index.d.ts +13 -1
  171. package/dist/index.js +4 -0
  172. package/package.json +5 -5
@@ -206,6 +206,7 @@
206
206
  {@const allowed = filterOperators(editorFields.find(item => item.key === node.field))}
207
207
  {@const collection = isCollectionOperator(node.operator)}
208
208
  {@const invalid = issues.some((issue) => issue.path === node.id)}
209
+ {@const describedby = issues.flatMap((issue, index) => issue.path === node.id ? [`${uid}-issue-${index}`] : []).join(' ') || undefined}
209
210
  <div class="filter-rule" data-testid="filter-builder-rule" data-filter-rule={node.id}>
210
211
  {#if node.readonly}
211
212
  <output aria-label={i18n.t('filter.readonly')}>{node.field} {node.operator} {JSON.stringify(node.value)}</output>
@@ -225,15 +226,20 @@
225
226
  </Select>
226
227
  </div>
227
228
  <div>
228
- <label for={`${uid}-${node.id}-value`}>{collection ? (chinese ? '值(JSON 数组)' : 'Value (JSON array)') : (chinese ? '值' : 'Value')}</label>
229
+ {#if collection && field}
230
+ <span id={`${uid}-${node.id}-value-label`}>{chinese ? '值' : 'Value'}</span>
231
+ {:else}
232
+ <label for={`${uid}-${node.id}-value`}>{collection ? (chinese ? '值(JSON 数组)' : 'Value (JSON array)') : (chinese ? '值' : 'Value')}</label>
233
+ {/if}
229
234
  {#if isNullOperator(node.operator)}
230
235
  <Input id={`${uid}-${node.id}-value`} value={chinese ? '无需填值' : 'No value required'} disabled />
231
236
  {:else if collection && field}
232
237
  <FilterCollectionInput {field} value={node.value}
238
+ {invalid} {describedby} labelledby={`${uid}-${node.id}-value-label`}
233
239
  range={node.operator === 'between' || node.operator === 'nbetween'}
234
240
  onchange={(value) => { if (!disabled && !node.readonly) node.value = value; }} />
235
241
  {:else if !collection && field?.options?.length}
236
- <Select id={`${uid}-${node.id}-value`} aria-invalid={invalid}
242
+ <Select id={`${uid}-${node.id}-value`} aria-invalid={invalid} aria-describedby={describedby}
237
243
  value={field.options.findIndex((option) => option.value === node.value) < 0 ? '' : String(field.options.findIndex((option) => option.value === node.value))}
238
244
  onchange={(event: Event) => {
239
245
  if (disabled || node.readonly || !(event.currentTarget instanceof HTMLSelectElement)) return;
@@ -247,15 +253,15 @@
247
253
  {#each field.options as option, index (index)}<option value={String(index)} disabled={option.disabled}>{option.label}</option>{/each}
248
254
  </Select>
249
255
  {:else if !collection && field?.type === 'boolean'}
250
- <Select id={`${uid}-${node.id}-value`} aria-invalid={invalid} value={valueText(node.value)}
256
+ <Select id={`${uid}-${node.id}-value`} aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)}
251
257
  onchange={(event: Event) => { if (event.currentTarget instanceof HTMLSelectElement) changeValue(node, event.currentTarget.value); }}>
252
258
  <option value="">{chinese ? '请选择' : 'Choose'}</option><option value="true">true</option><option value="false">false</option>
253
259
  </Select>
254
260
  {:else if !collection && isNumericField(field)}
255
- <Input id={`${uid}-${node.id}-value`} type="number" step="any" aria-invalid={invalid} value={typeof node.value === 'number' ? node.value : undefined}
261
+ <Input id={`${uid}-${node.id}-value`} type="number" step="any" aria-invalid={invalid} aria-describedby={describedby} value={typeof node.value === 'number' ? node.value : undefined}
256
262
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
257
263
  {:else}
258
- <Input id={`${uid}-${node.id}-value`} type="text" aria-invalid={invalid} value={valueText(node.value)} placeholder={collection ? '[1, 2]' : ''}
264
+ <Input id={`${uid}-${node.id}-value`} type="text" aria-invalid={invalid} aria-describedby={describedby} value={valueText(node.value)} placeholder={collection ? '[1, 2]' : ''}
259
265
  oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
260
266
  {/if}
261
267
  </div>
@@ -277,7 +283,7 @@
277
283
  <Button type="button" size="sm" disabled={disabled} data-testid="filter-builder-apply" onclick={apply}><Check size={14} aria-hidden="true" />{i18n.t('common.confirm')}</Button>
278
284
  </div>
279
285
  </div>
280
- {#if issues.length}<div role="alert" class="errors" data-testid="filter-builder-errors">{#each issues as issue, index (`${issue.path}-${issue.code}-${index}`)}<p>{issue.path}: {message(issue.code)}</p>{/each}</div>{/if}
286
+ {#if issues.length}<div role="alert" class="errors" data-testid="filter-builder-errors">{#each issues as issue, index (`${issue.path}-${issue.code}-${index}`)}<p id={`${uid}-issue-${index}`}>{issue.path}: {message(issue.code)}</p>{/each}</div>{/if}
281
287
  {#if !loadIssues.length}{@render groupEditor(root, root.wrapped || logicalOperator === 'or' ? 1 : 0, true)}{/if}
282
288
  </div>
283
289
 
@@ -292,7 +298,7 @@
292
298
  .filter-rule { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 2fr) auto; align-items: end; gap: .5rem; margin-block-start: .75rem; }
293
299
  .filter-rule > div { min-width: 0; }
294
300
  .filter-rule > output { grid-column: 1 / -2; overflow-wrap: anywhere; }
295
- .filter-rule label { display: block; margin-block-end: .25rem; color: var(--muted-foreground); }
301
+ .filter-rule label, .filter-rule > div > span { display: block; margin-block-end: .25rem; color: var(--muted-foreground); }
296
302
  .empty { padding-block: .75rem; color: var(--muted-foreground); }
297
303
  .errors { color: var(--destructive); overflow-wrap: anywhere; }
298
304
  .nested-group { align-items: flex-start; }
@@ -6,8 +6,10 @@
6
6
  import { Input } from './ui/input/index.js';
7
7
  import { Select } from './ui/select/index.js';
8
8
 
9
- let { field, value, range = false, onchange }: {
10
- field: FieldDefinition; value: unknown; range?: boolean; onchange: (value: unknown[]) => void;
9
+ let { field, value, range = false, labelledby, describedby, invalid = false, onchange }: {
10
+ field: FieldDefinition; value: unknown; range?: boolean;
11
+ labelledby?: string | undefined; describedby?: string | undefined; invalid?: boolean;
12
+ onchange: (value: unknown[]) => void;
11
13
  } = $props();
12
14
  const i18n = useTranslation();
13
15
  const values = $derived<unknown[]>(Array.isArray(value) ? value : range ? [null, null] : [null]);
@@ -17,14 +19,14 @@
17
19
  }
18
20
  </script>
19
21
 
20
- <div class="filter-collection">
22
+ <div class="filter-collection" role="group" aria-labelledby={labelledby} aria-describedby={describedby}>
21
23
  {#each values as item, index (index)}
22
24
  {@const label = range
23
25
  ? i18n.t(index === 0 ? 'filter.rangeStart' : 'filter.rangeEnd')
24
26
  : i18n.t('filter.collectionItem', { number: String(index + 1) })}
25
27
  <div class="filter-collection-item">
26
28
  {#if field.type === 'select'}
27
- <Select aria-label={label} value={String(field.options?.findIndex(option => option.value === item) ?? -1)}
29
+ <Select aria-label={label} aria-invalid={invalid} aria-describedby={describedby} value={String(field.options?.findIndex(option => option.value === item) ?? -1)}
28
30
  onchange={(event: Event) => {
29
31
  if (!(event.currentTarget instanceof HTMLSelectElement)) return;
30
32
  const option = field.options?.[Number(event.currentTarget.value)];
@@ -36,7 +38,7 @@
36
38
  {/each}
37
39
  </Select>
38
40
  {:else if field.type === 'boolean'}
39
- <Select aria-label={label} value={typeof item === 'boolean' ? String(item) : ''}
41
+ <Select aria-label={label} aria-invalid={invalid} aria-describedby={describedby} value={typeof item === 'boolean' ? String(item) : ''}
40
42
  onchange={(event: Event) => {
41
43
  if (!(event.currentTarget instanceof HTMLSelectElement)) return;
42
44
  update(index, event.currentTarget.value === 'true' ? true : event.currentTarget.value === 'false' ? false : null);
@@ -46,12 +48,12 @@
46
48
  <option value="false">{i18n.t('common.no')}</option>
47
49
  </Select>
48
50
  {:else if isNumericFilterField(field)}
49
- <Input type="number" step="any" aria-label={label} value={typeof item === 'number' ? item : undefined}
51
+ <Input type="number" step="any" aria-label={label} aria-invalid={invalid} aria-describedby={describedby} value={typeof item === 'number' ? item : undefined}
50
52
  oninput={(event: Event) => {
51
53
  if (event.currentTarget instanceof HTMLInputElement) update(index, event.currentTarget.value === '' ? null : event.currentTarget.valueAsNumber);
52
54
  }} />
53
55
  {:else}
54
- <Input type="text" aria-label={label} value={typeof item === 'string' ? item : ''}
56
+ <Input type="text" aria-label={label} aria-invalid={invalid} aria-describedby={describedby} value={typeof item === 'string' ? item : ''}
55
57
  oninput={(event: Event) => {
56
58
  if (event.currentTarget instanceof HTMLInputElement) update(index, event.currentTarget.value);
57
59
  }} />
@@ -3,6 +3,9 @@ type $$ComponentProps = {
3
3
  field: FieldDefinition;
4
4
  value: unknown;
5
5
  range?: boolean;
6
+ labelledby?: string | undefined;
7
+ describedby?: string | undefined;
8
+ invalid?: boolean;
6
9
  onchange: (value: unknown[]) => void;
7
10
  };
8
11
  declare const FilterCollectionInput: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -32,7 +32,7 @@
32
32
  if (result.success) {
33
33
  sent = true;
34
34
  } else {
35
- error = result.error?.message ?? i18n.t('common.operationFailed');
35
+ error = i18n.t('common.operationFailed');
36
36
  }
37
37
  }
38
38
  </script>
@@ -71,7 +71,7 @@
71
71
  <Button variant="outline" size="sm" onclick={onSearchClick} aria-label={i18n.t('common.search')} class="svadmin-header-search svadmin-u-77a2a20e90d4 svadmin-u-bfa603190748 svadmin-u-e7a768f922d2 svadmin-u-0e17f2bd9074 svadmin-u-5f22e64f2282 svadmin-u-05faf5c801ff">
72
72
  <Search class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
73
73
  <span class="svadmin-u-99d72c7fc3e2 svadmin-u-ee3c1259a368 svadmin-u-359090c2d529">{i18n.t('common.search')}</span>
74
- <kbd class="svadmin-u-99d72c7fc3e2 svadmin-u-e3fd07f96126 svadmin-u-3960ffc248d9 svadmin-u-a3899220f90e svadmin-u-07389a777c1f svadmin-u-ca6bcd4b6f3f svadmin-u-706701550477 svadmin-u-45d828117213 svadmin-u-465609a240a8 svadmin-u-1dc571a3609f svadmin-u-2689f3958069 svadmin-u-0e65706bcccd svadmin-u-bfa603190748">
74
+ <kbd style="flex-shrink: 0; white-space: nowrap" class="whitespace-nowrap svadmin-u-99d72c7fc3e2 svadmin-u-e3fd07f96126 svadmin-u-3960ffc248d9 svadmin-u-a3899220f90e svadmin-u-07389a777c1f svadmin-u-ca6bcd4b6f3f svadmin-u-706701550477 svadmin-u-45d828117213 svadmin-u-465609a240a8 svadmin-u-1dc571a3609f svadmin-u-2689f3958069 svadmin-u-0e65706bcccd svadmin-u-bfa603190748">
75
75
  <span class="svadmin-u-359090c2d529">⌘</span>K
76
76
  </kbd>
77
77
  </Button>
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, captureAuthSession, IMPORT_LIMITS, snapshotImportArtifact, snapshotImportTaskResult, useCan, useImport, useResourceContract, useSubmitTask, useTask } from '@svadmin/core';
2
+ import { captureAdminContext, captureAuthSession, IMPORT_LIMITS, invalidImportPreviewFailure, snapshotImportArtifact, snapshotImportTaskResult, useCan, useImport, useResourceContract, useSubmitTask, useTask } from '@svadmin/core';
3
3
  import { untrack } from 'svelte';
4
4
  import { definedReactiveOptions } from '@svadmin/core/options';
5
5
  import { useTranslation } from '@svadmin/core/i18n';
6
6
  import { parseContractCreateInput } from '@svadmin/core/resource-contract';
7
- import type { ImportArtifactProvider, TaskProvider } from '@svadmin/core';
7
+ import type { ImportArtifactProvider, ImportPreviewResult, TaskProvider } from '@svadmin/core';
8
8
  import { normalizeTaskStatus, resolveTaskProgress, canCancelTask } from './task-utils.js';
9
9
  import TaskStatusBadge from './TaskStatusBadge.svelte';
10
10
  import CancelTaskButton from './CancelTaskButton.svelte';
@@ -15,6 +15,9 @@
15
15
  import { Badge } from './ui/badge/index.js';
16
16
  import { Upload, ArrowRight, ArrowLeft, AlertCircle, Download, FileSpreadsheet, Loader2, RotateCw } from '@lucide/svelte';
17
17
 
18
+ /** 仅用于标记文件读取或 JSON 语法异常,避免把浏览器原始诊断展示给用户。 */
19
+ class ImportFileReadError extends Error {}
20
+
18
21
  interface Props {
19
22
  resourceName: string;
20
23
  open?: boolean;
@@ -30,6 +33,10 @@
30
33
  initialTaskId?: string;
31
34
  onTaskSubmitted?: (taskId: string) => void;
32
35
  onSuccess?: (result: { succeeded: number; failed: number }) => void;
36
+ /** 只做本地解析、映射和逐行校验,不调用 Provider 写入,也不提交、轮询或取消任务。 */
37
+ dryRun?: boolean;
38
+ /** 预览结果回调;接收隔离的只读快照,失败原因与失败报告一样只含安全通用文本。 */
39
+ onPreview?: (result: ImportPreviewResult) => void;
33
40
  }
34
41
 
35
42
  let {
@@ -46,6 +53,8 @@
46
53
  initialTaskId,
47
54
  onTaskSubmitted,
48
55
  onSuccess,
56
+ dryRun = false,
57
+ onPreview,
49
58
  }: Props = $props();
50
59
 
51
60
  const i18n = useTranslation();
@@ -78,14 +87,14 @@
78
87
  let parseToken = 0;
79
88
  let mounted = true;
80
89
  let cancelled = $state(false);
81
- let durableTaskId = $state<string | undefined>(untrack(() => initialTaskId));
82
- let durableTaskScope = $state.raw<typeof scope | undefined>(untrack(() => scope));
83
- const ownedTaskId = $derived(durableTaskScope && sameScope(durableTaskScope, scope) ? durableTaskId : undefined);
90
+ let durableTaskId = $state<string | undefined>(untrack(() => dryRun ? undefined : initialTaskId));
91
+ let durableTaskScope = $state.raw<typeof scope | undefined>(untrack(() => dryRun ? undefined : scope));
92
+ const ownedTaskId = $derived(!dryRun && durableTaskScope && sameScope(durableTaskScope, scope) ? durableTaskId : undefined);
84
93
  let durableHandledTaskId = $state<string | undefined>();
85
94
  let durableSubmitAttempt = $state.raw<object | undefined>();
86
95
  let artifactUploadController: AbortController | undefined;
87
96
  let durablePolling = $state(true);
88
- const durableMode = $derived(taskName !== undefined);
97
+ const durableMode = $derived(!dryRun && taskName !== undefined);
89
98
  const submitTask = useSubmitTask();
90
99
  const taskQuery = useTask(definedReactiveOptions({
91
100
  get taskId() { return ownedTaskId; },
@@ -101,12 +110,13 @@
101
110
  const durableStatus = $derived(normalizeTaskStatus(taskQuery.data?.status));
102
111
  const retryReceipt = $derived(durableStatus === 'completed'
103
112
  ? snapshotImportTaskResult(taskQuery.data?.result ?? taskQuery.data?.result_data)?.retry : undefined);
104
- const durableActive = $derived(durableStatus === 'queued' || durableStatus === 'processing'
105
- || !!durableSubmitAttempt);
113
+ const durableActive = $derived(!dryRun && (durableStatus === 'queued' || durableStatus === 'processing'
114
+ || !!durableSubmitAttempt));
106
115
 
107
116
  let importResult = $state<{ succeeded: number; failed: number } | null>(null);
108
117
  let succeededCount = $state(0);
109
118
  let failedRecords = $state<Array<{ row: Record<string, unknown>; error: string }>>([]);
119
+ let previewResult = $state.raw<ImportPreviewResult | null>(null);
110
120
 
111
121
  const importer = useImport(definedReactiveOptions({
112
122
  get resource() { return binding.resource; },
@@ -133,6 +143,7 @@
133
143
  durableTaskId = undefined;
134
144
  durableHandledTaskId = undefined;
135
145
  durableSubmitAttempt = undefined;
146
+ previewResult = null;
136
147
  selectedFile = null;
137
148
  fileName = file.name;
138
149
  rawHeaders = [];
@@ -144,13 +155,13 @@
144
155
  throw new Error(i18n.t('import.invalidLimits'));
145
156
  }
146
157
  if (file.size > maxBytes) throw new Error(i18n.t('import.limit'));
147
- const text = await file.text();
158
+ const text = await readFileText(file);
148
159
  if (!current(origin, token)) return;
149
160
  if (text.length > maxBytes) throw new Error(i18n.t('import.limit'));
150
161
  const cleanText = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
151
162
 
152
163
  if (file.name.toLowerCase().endsWith('.json')) {
153
- const json: unknown = JSON.parse(cleanText);
164
+ const json: unknown = parseImportJson(cleanText);
154
165
  if (Array.isArray(json) && json.length > maxRows) throw new Error(i18n.t('import.limit'));
155
166
  if (!Array.isArray(json) || json.length === 0 || json.some((item) => (
156
167
  typeof item !== 'object' || item === null || Array.isArray(item)
@@ -201,11 +212,29 @@
201
212
  currentStep = 2;
202
213
  } catch (error) {
203
214
  if (!current(origin, token)) return;
204
- fileError = error instanceof Error ? error.message : 'Unable to read the import file.';
215
+ fileError = error instanceof ImportFileReadError
216
+ ? i18n.t('import.fileReadFailed')
217
+ : error instanceof Error ? error.message : i18n.t('import.fileReadFailed');
205
218
  currentStep = 1;
206
219
  }
207
220
  }
208
221
 
222
+ async function readFileText(file: File): Promise<string> {
223
+ try {
224
+ return await file.text();
225
+ } catch {
226
+ throw new ImportFileReadError();
227
+ }
228
+ }
229
+
230
+ function parseImportJson(text: string): unknown {
231
+ try {
232
+ return JSON.parse(text);
233
+ } catch {
234
+ throw new ImportFileReadError();
235
+ }
236
+ }
237
+
209
238
  function handleDrop(e: DragEvent) {
210
239
  e.preventDefault();
211
240
  if (e.dataTransfer?.files?.[0]) {
@@ -244,20 +273,78 @@
244
273
  return record;
245
274
  }
246
275
 
247
- function validateMappedRows(): string | null {
276
+ function mapSourceRow(source: unknown[]): Record<string, unknown> {
277
+ return mapRow(Object.fromEntries(rawHeaders.map((header, column) => [header, source[column]])));
278
+ }
279
+
280
+ function snapshotPreviewResult(result: ImportPreviewResult): ImportPreviewResult {
281
+ const failedRows = result.failedRows.map(failure => Object.freeze({
282
+ row: failure.row,
283
+ error: failure.error,
284
+ }));
285
+ Object.freeze(failedRows);
286
+ return Object.freeze({
287
+ totalRows: result.totalRows,
288
+ importableRows: result.importableRows,
289
+ failedRows,
290
+ });
291
+ }
292
+
293
+ function evaluateMappedRows(): ImportPreviewResult {
294
+ const failedRows: ImportPreviewResult['failedRows'] = [];
295
+ for (const [index, source] of rawRows.entries()) {
296
+ try {
297
+ parseContractCreateInput(binding.resource, mapSourceRow(source));
298
+ } catch {
299
+ failedRows.push(invalidImportPreviewFailure(index + 1));
300
+ }
301
+ }
302
+ return snapshotPreviewResult({
303
+ totalRows: rawRows.length,
304
+ importableRows: rawRows.length - failedRows.length,
305
+ failedRows,
306
+ });
307
+ }
308
+
309
+ function firstInvalidMappedRow(): number | undefined {
248
310
  for (const [index, source] of rawRows.entries()) {
249
311
  try {
250
- const mapped = mapRow(Object.fromEntries(rawHeaders.map((header, column) => [header, source[column]])));
251
- parseContractCreateInput(binding.resource, mapped);
312
+ parseContractCreateInput(binding.resource, mapSourceRow(source));
252
313
  } catch {
253
- return i18n.t('import.invalidRow', { row: index + 1 });
314
+ return index + 1;
254
315
  }
255
316
  }
256
- return null;
317
+ return undefined;
318
+ }
319
+
320
+ function validateMappedRows(): string | null {
321
+ const row = firstInvalidMappedRow();
322
+ return row === undefined ? null : i18n.t('import.invalidRow', { row });
323
+ }
324
+
325
+ function previewImport() {
326
+ if (!selectedFile || rawRows.length === 0 || isImporting || !open || !allowed) return;
327
+ if (!dryRun && !onPreview) return;
328
+ const targets = Object.values(columnMapping).filter(Boolean);
329
+ if (targets.length === 0 || new Set(targets).size !== targets.length) {
330
+ fileError = 'Map at least one column, and use each target field only once.';
331
+ previewResult = null;
332
+ return;
333
+ }
334
+ fileError = null;
335
+ importError = null;
336
+ previewResult = evaluateMappedRows();
337
+ currentStep = 3;
338
+ try {
339
+ // 观察者只拿到隔离的只读快照;同步抛错或异步拒绝都不能改变已完成的本地预览。
340
+ const observerSnapshot = snapshotPreviewResult(previewResult);
341
+ void Promise.resolve(onPreview?.(observerSnapshot)).catch(() => {});
342
+ } catch { /* 观察者失败不能改变已完成的本地预览结果。 */ }
257
343
  }
258
344
 
259
345
  async function startImport() {
260
346
  if (!selectedFile || rawRows.length === 0 || isImporting || !open || !allowed) return;
347
+ if (dryRun) { previewImport(); return; }
261
348
  if (durableMode && (!activeTaskProvider || !taskName?.trim() || !taskIdempotencyKey?.trim())) {
262
349
  fileError = i18n.t('import.taskConfiguration');
263
350
  return;
@@ -275,6 +362,7 @@
275
362
  const origin = scope;
276
363
  const token = parseToken;
277
364
  const notifySuccess = onSuccess;
365
+ previewResult = null;
278
366
  currentStep = 3;
279
367
  succeededCount = 0;
280
368
  failedRecords = [];
@@ -292,8 +380,7 @@
292
380
  throw new Error(i18n.t('import.taskConfiguration'));
293
381
  }
294
382
  const records = taskArtifactProvider ? undefined : rawRows.map((source, index) => {
295
- const mapped = mapRow(Object.fromEntries(rawHeaders.map((header, column) => [header, source[column]])));
296
- try { return parseContractCreateInput(binding.resource, mapped); }
383
+ try { return parseContractCreateInput(binding.resource, mapSourceRow(source)); }
297
384
  catch { throw new Error(i18n.t('import.invalidRow', { row: index + 1 })); }
298
385
  });
299
386
  const uploaded = taskArtifactProvider
@@ -363,12 +450,12 @@
363
450
  succeededCount = result.succeeded.length;
364
451
  failedRecords = result.errored.map(item => ({
365
452
  row: item.request as Record<string, unknown>,
366
- error: item.error.message,
453
+ error: importFailureMessage(item.error, item.row),
367
454
  }));
368
455
  importResult = { succeeded: succeededCount, failed: failedRecords.length };
369
456
  } catch (error) {
370
457
  if (!current(origin, token)) return;
371
- importError = error instanceof Error ? error.message : 'Import failed.';
458
+ importError = importFailureMessage(error);
372
459
  importResult = null;
373
460
  }
374
461
  if (importResult) {
@@ -391,6 +478,33 @@
391
478
  left.taskIdempotencyKey === right.taskIdempotencyKey;
392
479
  }
393
480
 
481
+ /** Core 的 HttpError.message 只用于诊断;界面仅按安全 code 选择本地化文案。 */
482
+ function importFailureMessage(error: unknown, row?: number): string {
483
+ let code: unknown;
484
+ try {
485
+ if (typeof error === 'object' && error !== null) {
486
+ code = Object.getOwnPropertyDescriptor(error, 'code')?.value;
487
+ }
488
+ } catch { /* 错误对象属于不可信诊断来源。 */ }
489
+ if (code === 'IMPORT_LIMIT_EXCEEDED') return i18n.t('import.limit');
490
+ if (code === 'INVALID_RESOURCE_INPUT') {
491
+ const failedRow = row ?? failureRow(error);
492
+ if (failedRow !== undefined) return i18n.t('import.invalidRow', { row: failedRow });
493
+ }
494
+ return i18n.t('common.operationFailed', { defaultValue: 'Import failed' });
495
+ }
496
+
497
+ function failureRow(error: unknown): number | undefined {
498
+ try {
499
+ if (typeof error !== 'object' || error === null) return undefined;
500
+ const details: unknown = Object.getOwnPropertyDescriptor(error, 'details')?.value;
501
+ if (typeof details !== 'object' || details === null) return undefined;
502
+ const row: unknown = Object.getOwnPropertyDescriptor(details, 'row')?.value;
503
+ return typeof row === 'number' && Number.isSafeInteger(row) && row >= 1 && row <= maxRows
504
+ ? row : undefined;
505
+ } catch { return undefined; }
506
+ }
507
+
394
508
  async function retryFailedRows(): Promise<void> {
395
509
  const receipt = retryReceipt;
396
510
  const parentTaskId = ownedTaskId;
@@ -472,6 +586,7 @@
472
586
  succeededCount = 0;
473
587
  failedRecords = [];
474
588
  importResult = null;
589
+ previewResult = null;
475
590
  fileError = null;
476
591
  importError = null;
477
592
  cancelled = false;
@@ -497,20 +612,27 @@
497
612
 
498
613
  let observedScope = untrack(() => scope);
499
614
  let observedOpen = untrack(() => open);
615
+ let observedDryRun = untrack(() => dryRun);
500
616
  $effect.pre(() => {
501
- const changed = !sameScope(observedScope, scope) || (observedScope.allowed && !allowed);
617
+ const changed = !sameScope(observedScope, scope) || (observedScope.allowed && !allowed)
618
+ || observedDryRun !== dryRun;
502
619
  if (changed) {
503
620
  durableTaskId = undefined;
504
621
  durableTaskScope = undefined;
505
622
  durableHandledTaskId = undefined;
623
+ durableSubmitAttempt = undefined;
624
+ artifactUploadController?.abort();
625
+ artifactUploadController = undefined;
626
+ durablePolling = true;
506
627
  }
507
628
  if (changed || observedOpen !== open) untrack(reset);
508
629
  observedScope = scope;
509
630
  observedOpen = open;
631
+ observedDryRun = dryRun;
510
632
  });
511
633
  $effect(() => {
512
634
  const task = taskQuery.data;
513
- if (!open || !allowed || !durableTaskId || !task || durableHandledTaskId === durableTaskId) return;
635
+ if (dryRun || !open || !allowed || !durableTaskId || !task || durableHandledTaskId === durableTaskId) return;
514
636
  if (!['completed', 'failed', 'cancelled'].includes(durableStatus)) return;
515
637
  durableHandledTaskId = durableTaskId;
516
638
  durablePolling = false;
@@ -555,7 +677,41 @@
555
677
  {#if !allowed}
556
678
  <p role="status">{i18n.t('common.operationFailed', { defaultValue: 'Import is unavailable.' })}</p>
557
679
  {/if}
558
- {#if durableMode && (durableTaskId || durableSubmitAttempt || currentStep === 3)}
680
+ {#if currentStep === 3 && previewResult}
681
+ <div class="svadmin-u-b43b4c086d9a svadmin-u-03b4dd7f172b">
682
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4">
683
+ <FileSpreadsheet class="svadmin-u-cd0d9c512cdc svadmin-u-72470489ff4e" />
684
+ <span>{i18n.t('import.preview')}</span>
685
+ </div>
686
+ <p role="status">
687
+ {i18n.t('import.previewSummary', {
688
+ total: previewResult.totalRows,
689
+ importable: previewResult.importableRows,
690
+ failed: previewResult.failedRows.length,
691
+ })}
692
+ </p>
693
+ <p>{i18n.t('import.previewHint')}</p>
694
+ {#if previewResult.failedRows.length > 0}
695
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-811148b13d1e">
696
+ <AlertCircle class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3" />
697
+ <span>{i18n.t('import.previewFailed')}</span>
698
+ </div>
699
+ <ul>
700
+ {#each previewResult.failedRows.slice(0, 20) as failure (failure.row)}
701
+ <li>{i18n.t('import.previewFailedRow', { row: failure.row, error: failure.error })}</li>
702
+ {/each}
703
+ </ul>
704
+ {/if}
705
+ <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-77c08e015d14 svadmin-u-77a2a20e90d4">
706
+ <Button variant="outline" size="sm" onclick={() => { previewResult = null; currentStep = 2; }}>
707
+ {i18n.t('import.backToImport')}
708
+ </Button>
709
+ <Button variant="outline" size="sm" onclick={reset}>
710
+ {i18n.t('common.importAnother', { defaultValue: 'Import Another' })}
711
+ </Button>
712
+ </div>
713
+ </div>
714
+ {:else if durableMode && (durableTaskId || durableSubmitAttempt || currentStep === 3)}
559
715
  {#if durableTaskId}
560
716
  <p>{durableTaskId}</p>
561
717
  {#if taskQuery.isError}
@@ -672,16 +828,29 @@
672
828
  <ArrowLeft class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-618162408e7a" />
673
829
  {i18n.t('common.back', { defaultValue: 'Back' })}
674
830
  </Button>
675
- <Button size="sm" disabled={!allowed || isImporting} onclick={startImport}>
676
- {i18n.t('common.startImport', { defaultValue: 'Start Import' })}
677
- <ArrowRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-f58b02572ab2" />
678
- </Button>
831
+ {#if dryRun}
832
+ <Button size="sm" disabled={!allowed || isImporting} onclick={previewImport}>
833
+ {i18n.t('import.preview')}
834
+ <ArrowRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-f58b02572ab2" />
835
+ </Button>
836
+ {:else if onPreview}
837
+ <Button variant="outline" size="sm" disabled={!allowed || isImporting} onclick={previewImport}>
838
+ {i18n.t('common.preview')}
839
+ <ArrowRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-f58b02572ab2" />
840
+ </Button>
841
+ {/if}
842
+ {#if !dryRun}
843
+ <Button size="sm" disabled={!allowed || isImporting} onclick={startImport}>
844
+ {i18n.t('common.startImport', { defaultValue: 'Start Import' })}
845
+ <ArrowRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-f58b02572ab2" />
846
+ </Button>
847
+ {/if}
679
848
  </div>
680
849
  </div>
681
850
  {/if}
682
851
 
683
852
  <!-- Step 3: Progress & Summary -->
684
- {#if currentStep === 3 && !durableMode}
853
+ {#if currentStep === 3 && !durableMode && !previewResult}
685
854
  <div class="svadmin-u-b43b4c086d9a svadmin-u-03b4dd7f172b">
686
855
  <div class="svadmin-u-6f7e013d6499">
687
856
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-359090c2d529">
@@ -1,4 +1,4 @@
1
- import type { ImportArtifactProvider, TaskProvider } from '@svadmin/core';
1
+ import type { ImportArtifactProvider, ImportPreviewResult, TaskProvider } from '@svadmin/core';
2
2
  interface Props {
3
3
  resourceName: string;
4
4
  open?: boolean;
@@ -17,6 +17,10 @@ interface Props {
17
17
  succeeded: number;
18
18
  failed: number;
19
19
  }) => void;
20
+ /** 只做本地解析、映射和逐行校验,不调用 Provider 写入,也不提交、轮询或取消任务。 */
21
+ dryRun?: boolean;
22
+ /** 预览结果回调;接收隔离的只读快照,失败原因与失败报告一样只含安全通用文本。 */
23
+ onPreview?: (result: ImportPreviewResult) => void;
20
24
  }
21
25
  declare const ImportWizard: import("svelte").Component<Props, {}, "open">;
22
26
  type ImportWizard = ReturnType<typeof ImportWizard>;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
 
3
3
  import { captureAdminContext, inferResource } from '@svadmin/core';
4
+ import { useTranslation } from '@svadmin/core/i18n';
4
5
  import type { InferResult } from '@svadmin/core';
5
6
  import { Button } from './ui/button/index.js';
6
7
  import { Input } from './ui/input/index.js';
@@ -14,6 +15,7 @@
14
15
  import { Wand2, Copy, Check, RefreshCw, Loader2, AlertCircle } from '@lucide/svelte';
15
16
 
16
17
  const adminContext = captureAdminContext();
18
+ const i18n = useTranslation();
17
19
  const resources = $derived(adminContext.resources);
18
20
 
19
21
  let selectedResource = $state('');
@@ -70,9 +72,9 @@
70
72
  resourceName,
71
73
  response.data as Record<string, unknown>[],
72
74
  );
73
- } catch (e: unknown) {
75
+ } catch {
74
76
  if (epoch === inferenceEpoch) {
75
- error = e instanceof Error ? e.message : 'Failed to fetch data for inference.';
77
+ error = i18n.t('common.error') ?? 'Something went wrong';
76
78
  }
77
79
  } finally {
78
80
  if (epoch === inferenceEpoch) loading = false;