@svadmin/ui 0.72.0 → 0.74.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 (276) hide show
  1. package/README.md +62 -22
  2. package/THIRD_PARTY_NOTICES.md +52 -0
  3. package/dist/app.css +8488 -6297
  4. package/dist/app.theme.css +8514 -6374
  5. package/dist/class-properties.generated.d.ts +1 -0
  6. package/dist/class-properties.generated.js +2 -0
  7. package/dist/classnames.d.ts +8 -0
  8. package/dist/classnames.js +26 -0
  9. package/dist/component-registry.d.ts +14977 -0
  10. package/dist/component-registry.js +216 -0
  11. package/dist/components/AboutSettings.svelte +7 -1
  12. package/dist/components/AboutSettings.svelte.d.ts +4 -1
  13. package/dist/components/ActivityFeed.svelte +9 -3
  14. package/dist/components/ActivityFeed.svelte.d.ts +3 -0
  15. package/dist/components/AdminApp.svelte +19 -10
  16. package/dist/components/AdminApp.svelte.d.ts +6 -0
  17. package/dist/components/ApiSettings.svelte +41 -5
  18. package/dist/components/ApprovalCenter.svelte +283 -0
  19. package/dist/components/ApprovalCenter.svelte.d.ts +10 -0
  20. package/dist/components/AuditLogViewer.svelte +3 -1
  21. package/dist/components/AutoForm.svelte +53 -9
  22. package/dist/components/AutoForm.svelte.d.ts +3 -0
  23. package/dist/components/AutoTable.svelte +846 -43
  24. package/dist/components/AutoTable.svelte.d.ts +22 -1
  25. package/dist/components/BoundRecordDetailDrawer.svelte +17 -2
  26. package/dist/components/BoundRecordDetailDrawer.svelte.d.ts +4 -0
  27. package/dist/components/Breadcrumbs.svelte +11 -1
  28. package/dist/components/CancelTaskButton.svelte +12 -42
  29. package/dist/components/ColorPicker.svelte +73 -0
  30. package/dist/components/ColorPicker.svelte.d.ts +15 -0
  31. package/dist/components/ComboboxField.svelte +143 -34
  32. package/dist/components/ComboboxField.svelte.d.ts +4 -2
  33. package/dist/components/CreatePage.svelte +4 -0
  34. package/dist/components/CreatePage.svelte.d.ts +2 -0
  35. package/dist/components/DashboardView.svelte +99 -0
  36. package/dist/components/DashboardView.svelte.d.ts +14 -0
  37. package/dist/components/DatePicker.svelte +10 -0
  38. package/dist/components/DatePicker.svelte.d.ts +6 -0
  39. package/dist/components/DateRangeInput.svelte +155 -0
  40. package/dist/components/DateRangeInput.svelte.d.ts +35 -0
  41. package/dist/components/DateRangePicker.svelte +10 -0
  42. package/dist/components/DateRangePicker.svelte.d.ts +6 -0
  43. package/dist/components/DateTimeInput.svelte +134 -0
  44. package/dist/components/DateTimeInput.svelte.d.ts +27 -0
  45. package/dist/components/DateTimePicker.svelte +10 -0
  46. package/dist/components/DateTimePicker.svelte.d.ts +6 -0
  47. package/dist/components/DevTools.svelte +36 -28
  48. package/dist/components/DevframePanel.svelte +123 -0
  49. package/dist/components/DevframePanel.svelte.d.ts +3 -0
  50. package/dist/components/EditPage.svelte +4 -0
  51. package/dist/components/EditPage.svelte.d.ts +2 -0
  52. package/dist/components/ErrorSummary.svelte +52 -0
  53. package/dist/components/ErrorSummary.svelte.d.ts +14 -0
  54. package/dist/components/FieldRenderer.svelte +172 -99
  55. package/dist/components/FileUpload.svelte +412 -0
  56. package/dist/components/FileUpload.svelte.d.ts +43 -0
  57. package/dist/components/FilterBuilder.svelte +244 -265
  58. package/dist/components/FilterBuilder.svelte.d.ts +3 -0
  59. package/dist/components/FilterCollectionInput.svelte +80 -0
  60. package/dist/components/FilterCollectionInput.svelte.d.ts +10 -0
  61. package/dist/components/GanttChart.svelte +66 -14
  62. package/dist/components/GanttChart.svelte.d.ts +6 -0
  63. package/dist/components/Header.svelte +18 -2
  64. package/dist/components/ImportWizard.svelte +509 -100
  65. package/dist/components/ImportWizard.svelte.d.ts +11 -0
  66. package/dist/components/JsonSchemaForm.svelte +322 -98
  67. package/dist/components/JsonSchemaForm.svelte.d.ts +18 -1
  68. package/dist/components/KanbanBoard.svelte +195 -40
  69. package/dist/components/KanbanBoard.svelte.d.ts +9 -2
  70. package/dist/components/Layout.svelte +4 -3
  71. package/dist/components/LazyAutoForm.svelte +28 -0
  72. package/dist/components/LazyAutoForm.svelte.d.ts +8 -0
  73. package/dist/components/LazyAutoTable.svelte +28 -0
  74. package/dist/components/LazyAutoTable.svelte.d.ts +8 -0
  75. package/dist/components/LazySettingsPage.svelte +28 -0
  76. package/dist/components/LazySettingsPage.svelte.d.ts +8 -0
  77. package/dist/components/LazyShowPage.svelte +28 -0
  78. package/dist/components/LazyShowPage.svelte.d.ts +8 -0
  79. package/dist/components/LazyTaskQueueDrawer.svelte.d.ts +1 -1
  80. package/dist/components/ListPage.svelte +9 -1
  81. package/dist/components/ListPage.svelte.d.ts +5 -0
  82. package/dist/components/LoginPage.svelte +2 -2
  83. package/dist/components/MoneyInput.svelte +25 -0
  84. package/dist/components/MoneyInput.svelte.d.ts +20 -0
  85. package/dist/components/MultiTabKeepAlive.svelte +11 -6
  86. package/dist/components/NotificationsSettings.svelte +69 -2
  87. package/dist/components/NotificationsSettings.svelte.d.ts +25 -1
  88. package/dist/components/NumberInput.svelte +25 -0
  89. package/dist/components/NumberInput.svelte.d.ts +19 -0
  90. package/dist/components/NumericInput.svelte +110 -0
  91. package/dist/components/NumericInput.svelte.d.ts +23 -0
  92. package/dist/components/PercentInput.svelte +25 -0
  93. package/dist/components/PercentInput.svelte.d.ts +20 -0
  94. package/dist/components/PermissionMatrix.svelte +20 -1
  95. package/dist/components/PivotExportButton.svelte +157 -0
  96. package/dist/components/PivotExportButton.svelte.d.ts +16 -0
  97. package/dist/components/PivotTable.svelte +229 -74
  98. package/dist/components/PivotTable.svelte.d.ts +19 -2
  99. package/dist/components/QuickEditDrawer.svelte +4 -1
  100. package/dist/components/QuickEditDrawer.svelte.d.ts +2 -0
  101. package/dist/components/RadioGroup.svelte +121 -0
  102. package/dist/components/RadioGroup.svelte.d.ts +43 -0
  103. package/dist/components/RangeSlider.svelte +127 -0
  104. package/dist/components/RangeSlider.svelte.d.ts +22 -0
  105. package/dist/components/Rate.svelte +121 -0
  106. package/dist/components/Rate.svelte.d.ts +16 -0
  107. package/dist/components/RecordDetailDrawer.svelte +6 -2
  108. package/dist/components/RecordDetailDrawer.svelte.d.ts +4 -0
  109. package/dist/components/RelationPicker.svelte +341 -0
  110. package/dist/components/RelationPicker.svelte.d.ts +26 -0
  111. package/dist/components/ResourceOperationsPage.svelte +25 -19
  112. package/dist/components/ResourceOperationsPage.svelte.d.ts +5 -1
  113. package/dist/components/RetryTaskButton.svelte +12 -42
  114. package/dist/components/SecuritySettings.svelte +29 -5
  115. package/dist/components/SegmentedControl.svelte +145 -0
  116. package/dist/components/SegmentedControl.svelte.d.ts +38 -0
  117. package/dist/components/SettingsPage.svelte +3 -1
  118. package/dist/components/SettingsPage.svelte.d.ts +1 -0
  119. package/dist/components/ShowInferencer.svelte +4 -2
  120. package/dist/components/ShowInferencer.svelte.d.ts +2 -0
  121. package/dist/components/ShowPage.svelte +25 -6
  122. package/dist/components/ShowPage.svelte.d.ts +5 -0
  123. package/dist/components/Sidebar.svelte +5 -5
  124. package/dist/components/Slider.svelte +100 -0
  125. package/dist/components/Slider.svelte.d.ts +19 -0
  126. package/dist/components/SpreadsheetView.svelte +1037 -124
  127. package/dist/components/SpreadsheetView.svelte.d.ts +19 -0
  128. package/dist/components/StepsForm.svelte +5 -5
  129. package/dist/components/SvarAutoTable.svelte +39 -0
  130. package/dist/components/SvarAutoTable.svelte.d.ts +13 -0
  131. package/dist/components/SvarDataGrid.svelte +362 -0
  132. package/dist/components/SvarDataGrid.svelte.d.ts +57 -0
  133. package/dist/components/SvarGridCell.svelte +10 -0
  134. package/dist/components/SvarGridCell.svelte.d.ts +7 -0
  135. package/dist/components/SvarResourceRead.svelte +50 -0
  136. package/dist/components/SvarResourceRead.svelte.d.ts +23 -0
  137. package/dist/components/SvarResourceTable.svelte +63 -0
  138. package/dist/components/SvarResourceTable.svelte.d.ts +42 -0
  139. package/dist/components/SvarResourceTableView.svelte +364 -0
  140. package/dist/components/SvarResourceTableView.svelte.d.ts +4 -0
  141. package/dist/components/TagsInput.svelte +178 -0
  142. package/dist/components/TagsInput.svelte.d.ts +17 -0
  143. package/dist/components/TaskQueueDrawer.svelte +72 -16
  144. package/dist/components/TimePicker.svelte +10 -0
  145. package/dist/components/TimePicker.svelte.d.ts +6 -0
  146. package/dist/components/TreeSelect.svelte +440 -46
  147. package/dist/components/TreeSelect.svelte.d.ts +6 -1
  148. package/dist/components/TreeTable.svelte +484 -82
  149. package/dist/components/TreeTable.svelte.d.ts +24 -1
  150. package/dist/components/TwoFactorAuthPage.svelte +85 -48
  151. package/dist/components/TwoFactorAuthPage.svelte.d.ts +12 -1
  152. package/dist/components/VirtualTable.svelte +116 -20
  153. package/dist/components/VirtualTable.svelte.d.ts +5 -0
  154. package/dist/components/account/CompanyProfilePage.svelte +74 -38
  155. package/dist/components/account/GetStartedPage.svelte +2 -1
  156. package/dist/components/account/ImportMembersPage.svelte +22 -167
  157. package/dist/components/account/ImportMembersPage.svelte.d.ts +4 -1
  158. package/dist/components/account/MemberDirectory.svelte +156 -0
  159. package/dist/components/account/MemberDirectory.svelte.d.ts +19 -0
  160. package/dist/components/account/MembersStarterPage.svelte +9 -55
  161. package/dist/components/account/MembersStarterPage.svelte.d.ts +5 -1
  162. package/dist/components/account/SecurityLogPage.svelte +4 -1
  163. package/dist/components/account/SettingsPlainPage.svelte +7 -38
  164. package/dist/components/account/SettingsPlainPage.svelte.d.ts +4 -1
  165. package/dist/components/account/SettingsSidebarPage.svelte +7 -20
  166. package/dist/components/account/TeamMembersPage.svelte +4 -20
  167. package/dist/components/account/TeamMembersPage.svelte.d.ts +5 -1
  168. package/dist/components/account/UserProfilePage.svelte +3 -25
  169. package/dist/components/account/UserProfilePage.svelte.d.ts +17 -2
  170. package/dist/components/auto-table-grid.d.ts +26 -0
  171. package/dist/components/auto-table-grid.js +1 -0
  172. package/dist/components/buttons/ExportButton.svelte +56 -12
  173. package/dist/components/buttons/ExportButton.svelte.d.ts +11 -0
  174. package/dist/components/content/ContentPageHeader.svelte +12 -9
  175. package/dist/components/content/ContentPageShell.svelte +6 -9
  176. package/dist/components/content/FileBrowser.svelte +35 -0
  177. package/dist/components/content/FileBrowser.svelte.d.ts +14 -0
  178. package/dist/components/content/MetricBlock.svelte +10 -13
  179. package/dist/components/content/NetworkTable.svelte +3 -3
  180. package/dist/components/content/NetworkTable.svelte.d.ts +1 -0
  181. package/dist/components/content/PageToolbar.svelte +6 -10
  182. package/dist/components/content/ReferenceNotice.svelte +5 -0
  183. package/dist/components/content/ReferenceNotice.svelte.d.ts +18 -0
  184. package/dist/components/content/SectionHeader.svelte +8 -13
  185. package/dist/components/content/SettingsFieldRow.svelte +9 -23
  186. package/dist/components/content/SettingsGroup.svelte +11 -27
  187. package/dist/components/content/StatusBadge.svelte +3 -8
  188. package/dist/components/content/WorkspaceLayout.svelte +8 -12
  189. package/dist/components/enterprise/filter-tree.d.ts +48 -0
  190. package/dist/components/enterprise/filter-tree.js +224 -0
  191. package/dist/components/enterprise/json-schema-form.d.ts +39 -0
  192. package/dist/components/enterprise/json-schema-form.js +195 -0
  193. package/dist/components/enterprise/recursive-schema-form.d.ts +30 -0
  194. package/dist/components/enterprise/recursive-schema-form.js +196 -0
  195. package/dist/components/enterprise/spreadsheet-formula.d.ts +12 -0
  196. package/dist/components/enterprise/spreadsheet-formula.js +245 -0
  197. package/dist/components/export-reference.d.ts +2 -0
  198. package/dist/components/export-reference.js +9 -0
  199. package/dist/components/fieldComponentMap.js +2 -0
  200. package/dist/components/fields/DateField.svelte +12 -12
  201. package/dist/components/fields/date-display.d.ts +6 -0
  202. package/dist/components/fields/date-display.js +24 -0
  203. package/dist/components/fields/date-field-value.d.ts +2 -0
  204. package/dist/components/fields/date-field-value.js +13 -0
  205. package/dist/components/json-schema-form-state.d.ts +37 -0
  206. package/dist/components/json-schema-form-state.js +168 -0
  207. package/dist/components/network/TeamCrewTablePage.svelte +6 -1
  208. package/dist/components/network/UserCardsNFTPage.svelte +2 -0
  209. package/dist/components/profile/PublicProfilePage.svelte +2 -0
  210. package/dist/components/record-detail.svelte.d.ts +3 -0
  211. package/dist/components/record-detail.svelte.js +10 -4
  212. package/dist/components/saved-list-views.d.ts +74 -1
  213. package/dist/components/saved-list-views.js +191 -3
  214. package/dist/components/svar-grid-cells.d.ts +14 -0
  215. package/dist/components/svar-grid-cells.js +2 -0
  216. package/dist/components/svar-grid-contract.d.ts +80 -0
  217. package/dist/components/svar-grid-contract.js +159 -0
  218. package/dist/components/svar-grid-interactions.d.ts +54 -0
  219. package/dist/components/svar-grid-interactions.js +174 -0
  220. package/dist/components/svar-grid-loading.d.ts +40 -0
  221. package/dist/components/svar-grid-loading.js +180 -0
  222. package/dist/components/svar-grid-operations.d.ts +37 -0
  223. package/dist/components/svar-grid-operations.js +186 -0
  224. package/dist/components/svar-grid-panes.d.ts +34 -0
  225. package/dist/components/svar-grid-panes.js +66 -0
  226. package/dist/components/svar-grid-resource-loading.d.ts +18 -0
  227. package/dist/components/svar-grid-resource-loading.js +68 -0
  228. package/dist/components/svar-grid-resource-session.svelte.d.ts +29 -0
  229. package/dist/components/svar-grid-resource-session.svelte.js +61 -0
  230. package/dist/components/table-contract.d.ts +12 -1
  231. package/dist/components/task-action.svelte.d.ts +13 -0
  232. package/dist/components/task-action.svelte.js +73 -0
  233. package/dist/components/ui/DesignSystemShowcase.svelte +164 -0
  234. package/dist/components/ui/DesignSystemShowcase.svelte.d.ts +3 -0
  235. package/dist/components/ui/badge/badge-variants.d.ts +3 -2
  236. package/dist/components/ui/badge/badge-variants.js +2 -1
  237. package/dist/components/ui/badge/badge.svelte +3 -0
  238. package/dist/components/ui/badge/badge.svelte.d.ts +1 -0
  239. package/dist/components/ui/button/button-variants.d.ts +5 -4
  240. package/dist/components/ui/button/button-variants.js +7 -2
  241. package/dist/components/ui/checkbox/checkbox.svelte +5 -1
  242. package/dist/components/ui/checkbox/checkbox.svelte.d.ts +5 -1
  243. package/dist/components/ui/input/input.svelte +8 -6
  244. package/dist/components/ui/input/input.svelte.d.ts +4 -1
  245. package/dist/components/ui/select/select.svelte +4 -1
  246. package/dist/components/ui/select/select.svelte.d.ts +2 -1
  247. package/dist/components/ui/textarea/textarea.svelte +2 -1
  248. package/dist/components.css +3110 -0
  249. package/dist/date-input-policy.d.ts +4 -0
  250. package/dist/date-input-policy.js +35 -0
  251. package/dist/design-contract.d.ts +13 -0
  252. package/dist/design-contract.js +11 -0
  253. package/dist/design-system-components.d.ts +100 -0
  254. package/dist/design-system-components.js +101 -0
  255. package/dist/design-system.d.ts +15 -0
  256. package/dist/design-system.js +7 -0
  257. package/dist/devtools-bridge.d.ts +80 -0
  258. package/dist/devtools-bridge.js +92 -0
  259. package/dist/index.d.ts +50 -1
  260. package/dist/index.js +32 -2
  261. package/dist/page-chrome.css +12 -49
  262. package/dist/recipes.d.ts +960 -0
  263. package/dist/recipes.js +264 -0
  264. package/dist/rendering/index.d.ts +87 -0
  265. package/dist/rendering/index.js +117 -0
  266. package/dist/styles/aliases.css +49 -0
  267. package/dist/styles/compatibility.epilogue.css +384 -0
  268. package/dist/styles/compatibility.prelude.css +5513 -0
  269. package/dist/styles/conditional-states.css +28 -0
  270. package/dist/styles/conditional-utilities.css +22 -0
  271. package/dist/tree-table-model.d.ts +24 -0
  272. package/dist/tree-table-model.js +111 -0
  273. package/dist/utils.d.ts +1 -1
  274. package/dist/utils.js +1 -1
  275. package/package.json +40 -10
  276. package/styles-compatibility.json +12 -0
@@ -1,17 +1,34 @@
1
1
  <script lang="ts">
2
- import { getResource, captureAdminContext } from '@svadmin/core';
2
+ import { captureAdminContext, captureAuthSession, IMPORT_LIMITS, snapshotImportArtifact, snapshotImportTaskResult, useCan, useImport, useResourceContract, useSubmitTask, useTask } from '@svadmin/core';
3
+ import { untrack } from 'svelte';
4
+ import { definedReactiveOptions } from '@svadmin/core/options';
3
5
  import { useTranslation } from '@svadmin/core/i18n';
6
+ import { parseContractCreateInput } from '@svadmin/core/resource-contract';
7
+ import type { ImportArtifactProvider, TaskProvider } from '@svadmin/core';
8
+ import { normalizeTaskStatus, resolveTaskProgress, canCancelTask } from './task-utils.js';
9
+ import TaskStatusBadge from './TaskStatusBadge.svelte';
10
+ import CancelTaskButton from './CancelTaskButton.svelte';
4
11
  import { parseCSV, toCsv } from '@svadmin/core';
5
12
  import * as Dialog from './ui/dialog/index.js';
6
13
  import { Button } from './ui/button/index.js';
7
14
  import { Progress } from './ui/progress/index.js';
8
15
  import { Badge } from './ui/badge/index.js';
9
- import { Upload, ArrowRight, ArrowLeft, AlertCircle, Download, FileSpreadsheet, Loader2 } from '@lucide/svelte';
16
+ import { Upload, ArrowRight, ArrowLeft, AlertCircle, Download, FileSpreadsheet, Loader2, RotateCw } from '@lucide/svelte';
10
17
 
11
18
  interface Props {
12
19
  resourceName: string;
13
20
  open?: boolean;
14
21
  batchSize?: number;
22
+ maxRows?: number;
23
+ maxBytes?: number;
24
+ taskName?: string;
25
+ retryTaskName?: string;
26
+ taskProvider?: TaskProvider;
27
+ /** 上传原文件并以 artifact 引用提交任务,避免任务请求重复携带 records。 */
28
+ taskArtifactProvider?: ImportArtifactProvider;
29
+ taskIdempotencyKey?: string;
30
+ initialTaskId?: string;
31
+ onTaskSubmitted?: (taskId: string) => void;
15
32
  onSuccess?: (result: { succeeded: number; failed: number }) => void;
16
33
  }
17
34
 
@@ -19,69 +36,174 @@
19
36
  resourceName,
20
37
  open = $bindable(false),
21
38
  batchSize = 20,
39
+ maxRows = IMPORT_LIMITS.maxRows,
40
+ maxBytes = IMPORT_LIMITS.maxBytes,
41
+ taskName,
42
+ retryTaskName,
43
+ taskProvider,
44
+ taskArtifactProvider,
45
+ taskIdempotencyKey,
46
+ initialTaskId,
47
+ onTaskSubmitted,
22
48
  onSuccess,
23
49
  }: Props = $props();
24
50
 
25
51
  const i18n = useTranslation();
26
- const adminContext = captureAdminContext();
27
- const resource = $derived(getResource(resourceName));
52
+ const context = captureAdminContext();
53
+ const binding = useResourceContract(() => resourceName);
54
+ const resource = $derived(context.getResource(resourceName));
55
+ const can = useCan(() => ({ resource: resourceName, action: 'import' }));
56
+ const session = $derived(captureAuthSession(context.authProvider));
57
+ const allowed = $derived(can.allowed === true && resource.canCreate !== false && session.available);
58
+ const activeTaskProvider = $derived(taskProvider ?? context.taskProvider);
59
+ const scope = $derived({
60
+ contract: binding.resource, provider: context.providers?.[binding.dataProviderName],
61
+ tenant: context.tenantCacheKey?.__svadminTenant, meta: binding.meta,
62
+ auth: context.authProvider, router: context.routerProvider, session, allowed,
63
+ taskProvider: activeTaskProvider, taskName, retryTaskName, taskIdempotencyKey, taskArtifactProvider,
64
+ access: context.accessControlProvider,
65
+ });
28
66
  const availableFields = $derived(
29
67
  resource.fields.filter((f) => f.key !== (resource.primaryKey ?? 'id') && f.showInForm !== false)
30
68
  );
31
69
 
32
70
  let currentStep = $state<1 | 2 | 3>(1);
33
71
  let fileName = $state('');
72
+ let selectedFile = $state<File | null>(null);
34
73
  let rawHeaders = $state<string[]>([]);
35
- let rawRows = $state<string[][]>([]);
74
+ let rawRows = $state<unknown[][]>([]);
36
75
  let columnMapping = $state<Record<string, string>>({}); // header -> fieldKey or ''
76
+ let fileError = $state<string | null>(null);
77
+ let importError = $state<string | null>(null);
78
+ let parseToken = 0;
79
+ let mounted = true;
80
+ 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);
84
+ let durableHandledTaskId = $state<string | undefined>();
85
+ let durableSubmitAttempt = $state.raw<object | undefined>();
86
+ let artifactUploadController: AbortController | undefined;
87
+ let durablePolling = $state(true);
88
+ const durableMode = $derived(taskName !== undefined);
89
+ const submitTask = useSubmitTask();
90
+ const taskQuery = useTask(definedReactiveOptions({
91
+ get taskId() { return ownedTaskId; },
92
+ get taskProvider() { return activeTaskProvider; },
93
+ get queryOptions() {
94
+ return {
95
+ enabled: open && allowed && durableMode && !!activeTaskProvider && !!ownedTaskId,
96
+ refetchInterval: open && allowed && durableMode && ownedTaskId && durablePolling ? 2000 : false as const,
97
+ refetchOnWindowFocus: true,
98
+ };
99
+ },
100
+ }));
101
+ const durableStatus = $derived(normalizeTaskStatus(taskQuery.data?.status));
102
+ const retryReceipt = $derived(durableStatus === 'completed'
103
+ ? snapshotImportTaskResult(taskQuery.data?.result ?? taskQuery.data?.result_data)?.retry : undefined);
104
+ const durableActive = $derived(durableStatus === 'queued' || durableStatus === 'processing'
105
+ || !!durableSubmitAttempt);
37
106
 
38
- let isImporting = $state(false);
39
- let processedCount = $state(0);
107
+ let importResult = $state<{ succeeded: number; failed: number } | null>(null);
40
108
  let succeededCount = $state(0);
41
109
  let failedRecords = $state<Array<{ row: Record<string, unknown>; error: string }>>([]);
42
110
 
111
+ const importer = useImport(definedReactiveOptions({
112
+ get resource() { return binding.resource; },
113
+ get dataProviderName() { return binding.dataProviderName; },
114
+ get batchSize() { return batchSize; },
115
+ get maxRows() { return maxRows; },
116
+ get maxBytes() { return maxBytes; },
117
+ get enabled() { return open && allowed; },
118
+ mapData: mapRow,
119
+ }));
120
+ const isImporting = $derived(importer.isLoading || durableActive);
121
+ const processedCount = $derived(importer.progress.processedAmount);
122
+ const totalCount = $derived(importer.progress.totalAmount || rawRows.length);
43
123
  const progressPercent = $derived(
44
- rawRows.length > 0 ? Math.min(100, Math.round((processedCount / rawRows.length) * 100)) : 0
124
+ totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : 0
45
125
  );
46
126
 
47
- async function handleFileSelect(selectedFile: File) {
48
- fileName = selectedFile.name;
49
- const text = await selectedFile.text();
50
- const cleanText = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
127
+ async function handleFileSelect(file: File) {
128
+ if (!open || !allowed || isImporting) return;
129
+ const origin = scope;
130
+ const token = ++parseToken;
131
+ fileError = null;
132
+ importError = null;
133
+ durableTaskId = undefined;
134
+ durableHandledTaskId = undefined;
135
+ durableSubmitAttempt = undefined;
136
+ selectedFile = null;
137
+ fileName = file.name;
138
+ rawHeaders = [];
139
+ rawRows = [];
140
+ columnMapping = {};
141
+ try {
142
+ if (!Number.isSafeInteger(maxRows) || maxRows < 1 || maxRows > IMPORT_LIMITS.maxRows
143
+ || !Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > IMPORT_LIMITS.maxBytes) {
144
+ throw new Error(i18n.t('import.invalidLimits'));
145
+ }
146
+ if (file.size > maxBytes) throw new Error(i18n.t('import.limit'));
147
+ const text = await file.text();
148
+ if (!current(origin, token)) return;
149
+ if (text.length > maxBytes) throw new Error(i18n.t('import.limit'));
150
+ const cleanText = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
51
151
 
52
- if (fileName.endsWith('.json')) {
53
- try {
54
- const json = JSON.parse(cleanText);
55
- if (Array.isArray(json) && json.length > 0) {
56
- const keys = Object.keys(json[0]);
57
- rawHeaders = keys;
58
- rawRows = json.map((item) => keys.map((k) => String(item[k] ?? '')));
152
+ if (file.name.toLowerCase().endsWith('.json')) {
153
+ const json: unknown = JSON.parse(cleanText);
154
+ if (Array.isArray(json) && json.length > maxRows) throw new Error(i18n.t('import.limit'));
155
+ if (!Array.isArray(json) || json.length === 0 || json.some((item) => (
156
+ typeof item !== 'object' || item === null || Array.isArray(item)
157
+ ))) {
158
+ throw new Error('The JSON file must contain a non-empty array of records.');
159
+ }
160
+ const records = json as Record<string, unknown>[];
161
+ if (records.length > maxRows) throw new Error(i18n.t('import.limit'));
162
+ const keys = [...new Set(records.flatMap(item => Object.keys(item)))];
163
+ if (keys.length === 0) throw new Error('The import file has no columns.');
164
+ rawHeaders = keys;
165
+ rawRows = records.map((item) => keys.map((key) => item[key]));
166
+ } else if (file.name.toLowerCase().endsWith('.csv')) {
167
+ const rows = parseCSV(cleanText);
168
+ const firstRow = rows[0];
169
+ if (!firstRow || firstRow.length === 0 || firstRow.every((cell) => !cell.trim())) {
170
+ throw new Error('The CSV file has no header row.');
171
+ }
172
+ if (firstRow.some(header => !header.trim()) || new Set(firstRow).size !== firstRow.length) {
173
+ throw new Error('CSV column names must be non-empty and unique.');
174
+ }
175
+ const records = rows.slice(1).filter((r: string[]) =>
176
+ r.some((cell: string) => cell.trim().length > 0));
177
+ if (records.length === 0) throw new Error('The import file has no records.');
178
+ if (records.length > maxRows) throw new Error(i18n.t('import.limit'));
179
+ if (records.some((row) => row.length !== firstRow.length)) {
180
+ throw new Error('The CSV file contains rows with inconsistent column counts.');
59
181
  }
60
- } catch {
61
- /* invalid json */
62
- }
63
- } else {
64
- const rows = parseCSV(cleanText);
65
- const firstRow = rows[0];
66
- if (firstRow) {
67
182
  rawHeaders = firstRow;
68
- rawRows = rows.slice(1).filter((r: string[]) => r.some((cell: string) => cell.trim().length > 0));
183
+ rawRows = records;
184
+ } else {
185
+ throw new Error('Only CSV and JSON files are supported.');
69
186
  }
70
- }
71
187
 
72
- // Auto-match headers to fields by key or label
73
- const initialMapping: Record<string, string> = {};
74
- for (const h of rawHeaders) {
75
- const normalized = h.toLowerCase().trim().replace(/[-_]/g, '');
76
- const match = availableFields.find(
77
- (f) =>
78
- f.key.toLowerCase().replace(/[-_]/g, '') === normalized ||
79
- f.label.toLowerCase().replace(/[-_]/g, '') === normalized
80
- );
81
- initialMapping[h] = match ? match.key : '';
188
+ if (!current(origin, token)) return;
189
+ selectedFile = file;
190
+ const initialMapping: Record<string, string> = {};
191
+ for (const h of rawHeaders) {
192
+ const normalized = h.toLowerCase().trim().replace(/[-_]/g, '');
193
+ const match = availableFields.find(
194
+ (f) =>
195
+ f.key.toLowerCase().replace(/[-_]/g, '') === normalized ||
196
+ f.label.toLowerCase().replace(/[-_]/g, '') === normalized
197
+ );
198
+ initialMapping[h] = match ? match.key : '';
199
+ }
200
+ columnMapping = initialMapping;
201
+ currentStep = 2;
202
+ } catch (error) {
203
+ if (!current(origin, token)) return;
204
+ fileError = error instanceof Error ? error.message : 'Unable to read the import file.';
205
+ currentStep = 1;
82
206
  }
83
- columnMapping = initialMapping;
84
- currentStep = 2;
85
207
  }
86
208
 
87
209
  function handleDrop(e: DragEvent) {
@@ -91,63 +213,228 @@
91
213
  }
92
214
  }
93
215
 
216
+ function mapRow(row: Record<string, unknown>): Record<string, unknown> {
217
+ const record: Record<string, unknown> = {};
218
+ rawHeaders.forEach((header) => {
219
+ const targetFieldKey = columnMapping[header];
220
+ if (!targetFieldKey) return;
221
+ const fieldDef = availableFields.find((f) => f.key === targetFieldKey);
222
+ let val: unknown = row[header];
223
+ if (typeof val === 'string') {
224
+ if (fieldDef?.type === 'number' || fieldDef?.type === 'currency' || fieldDef?.type === 'percent') {
225
+ const num = val.trim() === '' ? NaN : Number(val);
226
+ val = val.trim() === '' ? undefined : Number.isFinite(num) ? num : val;
227
+ } else if (fieldDef?.type === 'boolean') {
228
+ const normalized = val.trim().toLowerCase();
229
+ if (['true', '1', 'yes', '是'].includes(normalized)) val = true;
230
+ else if (['false', '0', 'no', '否'].includes(normalized)) val = false;
231
+ } else if (fieldDef?.type === 'select' && fieldDef.options && selectedFile?.name.toLowerCase().endsWith('.csv')) {
232
+ const matches = fieldDef.options.filter(option => String(option.value) === val);
233
+ const match = matches[0];
234
+ if (matches.length !== 1 || !match || match.disabled) throw new Error('Invalid import option.');
235
+ val = match.value;
236
+ } else if (fieldDef?.type === 'json' && selectedFile?.name.toLowerCase().endsWith('.csv')) {
237
+ val = JSON.parse(val);
238
+ }
239
+ }
240
+ if (val !== undefined) {
241
+ Object.defineProperty(record, targetFieldKey, { value: val, enumerable: true });
242
+ }
243
+ });
244
+ return record;
245
+ }
246
+
247
+ function validateMappedRows(): string | null {
248
+ for (const [index, source] of rawRows.entries()) {
249
+ try {
250
+ const mapped = mapRow(Object.fromEntries(rawHeaders.map((header, column) => [header, source[column]])));
251
+ parseContractCreateInput(binding.resource, mapped);
252
+ } catch {
253
+ return i18n.t('import.invalidRow', { row: index + 1 });
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+
94
259
  async function startImport() {
260
+ if (!selectedFile || rawRows.length === 0 || isImporting || !open || !allowed) return;
261
+ if (durableMode && (!activeTaskProvider || !taskName?.trim() || !taskIdempotencyKey?.trim())) {
262
+ fileError = i18n.t('import.taskConfiguration');
263
+ return;
264
+ }
265
+ const targets = Object.values(columnMapping).filter(Boolean);
266
+ if (targets.length === 0 || new Set(targets).size !== targets.length) {
267
+ fileError = 'Map at least one column, and use each target field only once.';
268
+ return;
269
+ }
270
+ const mappingError = validateMappedRows();
271
+ if (mappingError) {
272
+ fileError = mappingError;
273
+ return;
274
+ }
275
+ const origin = scope;
276
+ const token = parseToken;
277
+ const notifySuccess = onSuccess;
95
278
  currentStep = 3;
96
- isImporting = true;
97
- processedCount = 0;
98
279
  succeededCount = 0;
99
280
  failedRecords = [];
100
-
101
- const provider = adminContext.getDataProviderForResource(resourceName);
102
-
103
- // Build mapped records
104
- const recordsToImport: Record<string, unknown>[] = [];
105
- for (const row of rawRows) {
106
- const record: Record<string, unknown> = {};
107
- rawHeaders.forEach((header, idx) => {
108
- const targetFieldKey = columnMapping[header];
109
- if (targetFieldKey) {
110
- const fieldDef = availableFields.find((f) => f.key === targetFieldKey);
111
- let val: unknown = row[idx] ?? '';
112
- if (fieldDef?.type === 'number') {
113
- const num = Number(val);
114
- val = isNaN(num) ? val : num;
115
- } else if (fieldDef?.type === 'boolean') {
116
- val = val === 'true' || val === '1' || val === 'yes' || val === '是';
117
- }
118
- record[targetFieldKey] = val;
281
+ importResult = null;
282
+ importError = null;
283
+ cancelled = false;
284
+ if (durableMode) {
285
+ if (durableTaskId || durableSubmitAttempt) return;
286
+ const attempt = {};
287
+ durableSubmitAttempt = attempt;
288
+ const uploadController = new AbortController();
289
+ artifactUploadController = uploadController;
290
+ try {
291
+ if (!activeTaskProvider || !taskName?.trim() || !taskIdempotencyKey?.trim()) {
292
+ throw new Error(i18n.t('import.taskConfiguration'));
119
293
  }
120
- });
121
- recordsToImport.push(record);
122
- }
123
-
124
- // Execute in batches
125
- for (let i = 0; i < recordsToImport.length; i += batchSize) {
126
- const batch = recordsToImport.slice(i, i + batchSize);
127
- if (provider.createMany) {
128
- try {
129
- await provider.createMany({ resource: resourceName, variables: batch });
130
- succeededCount += batch.length;
131
- } catch (err) {
132
- batch.forEach((item) => {
133
- failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
134
- });
294
+ 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); }
297
+ catch { throw new Error(i18n.t('import.invalidRow', { row: index + 1 })); }
298
+ });
299
+ const uploaded = taskArtifactProvider
300
+ ? snapshotImportArtifact(await taskArtifactProvider.upload({
301
+ file: selectedFile,
302
+ fileName,
303
+ size: selectedFile.size,
304
+ resource: binding.resource.name,
305
+ idempotencyKey: JSON.stringify(['import-artifact', taskIdempotencyKey]),
306
+ signal: uploadController.signal,
307
+ }))
308
+ : undefined;
309
+ if (durableSubmitAttempt !== attempt || !current(origin, token) || uploadController.signal.aborted) return;
310
+ if (taskArtifactProvider && !uploaded) throw new Error(i18n.t('import.taskPayloadLimit'));
311
+ if (uploaded && (uploaded.size !== selectedFile.size || uploaded.fileName !== fileName)) {
312
+ throw new Error(i18n.t('import.taskPayloadLimit'));
135
313
  }
136
- } else {
137
- for (const item of batch) {
138
- try {
139
- await provider.create({ resource: resourceName, variables: item });
140
- succeededCount++;
141
- } catch (err) {
142
- failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
143
- }
314
+ const body = uploaded ? {
315
+ protocolVersion: 2,
316
+ resource: binding.resource.name,
317
+ fileName,
318
+ mapping: { ...columnMapping },
319
+ artifact: uploaded,
320
+ } : {
321
+ protocolVersion: 1,
322
+ resource: binding.resource.name,
323
+ fileName,
324
+ mapping: { ...columnMapping },
325
+ records,
326
+ };
327
+ const encoded = JSON.stringify(body);
328
+ if (new TextEncoder().encode(encoded).byteLength > maxBytes) {
329
+ fileError = i18n.t('import.taskPayloadLimit');
330
+ currentStep = 2;
331
+ return;
144
332
  }
333
+ const handle = await submitTask.mutation.mutateAsync({
334
+ taskName: taskName.trim(),
335
+ taskProvider: activeTaskProvider,
336
+ successNotification: false,
337
+ errorNotification: false,
338
+ options: {
339
+ body,
340
+ idempotencyKey: taskIdempotencyKey,
341
+ ...(binding.meta === undefined ? {} : { meta: binding.meta }),
342
+ },
343
+ });
344
+ if (durableSubmitAttempt !== attempt || !current(origin, token)) return;
345
+ durableTaskId = handle.id;
346
+ durableTaskScope = scope;
347
+ durableHandledTaskId = undefined;
348
+ currentStep = 3;
349
+ try { void Promise.resolve(onTaskSubmitted?.(handle.id)).catch(() => {}); }
350
+ catch { /* 观察者失败不重发任务。 */ }
351
+ } catch {
352
+ if (durableSubmitAttempt !== attempt || !current(origin, token)) return;
353
+ importError = i18n.t('import.taskUnconfirmed');
354
+ } finally {
355
+ if (artifactUploadController === uploadController) artifactUploadController = undefined;
356
+ if (durableSubmitAttempt === attempt) durableSubmitAttempt = undefined;
145
357
  }
146
- processedCount = Math.min(recordsToImport.length, i + batch.length);
358
+ return;
147
359
  }
360
+ try {
361
+ const result = await importer.handleChange({ file: selectedFile });
362
+ if (!current(origin, token)) return;
363
+ succeededCount = result.succeeded.length;
364
+ failedRecords = result.errored.map(item => ({
365
+ row: item.request as Record<string, unknown>,
366
+ error: item.error.message,
367
+ }));
368
+ importResult = { succeeded: succeededCount, failed: failedRecords.length };
369
+ } catch (error) {
370
+ if (!current(origin, token)) return;
371
+ importError = error instanceof Error ? error.message : 'Import failed.';
372
+ importResult = null;
373
+ }
374
+ if (importResult) {
375
+ try { notifySuccess?.(importResult); }
376
+ catch { /* 消费方回调失败不能把已完成的写入改判为导入失败。 */ }
377
+ }
378
+ }
379
+
380
+ function current(origin: typeof scope, token: number): boolean {
381
+ return mounted && open && allowed && sameScope(origin, scope) && origin.session.isCurrent() && token === parseToken;
382
+ }
383
+
384
+ function sameScope(left: typeof scope, right: typeof scope): boolean {
385
+ return left.contract === right.contract && left.provider === right.provider &&
386
+ left.tenant === right.tenant && JSON.stringify(left.meta) === JSON.stringify(right.meta) &&
387
+ left.auth === right.auth && left.session.cacheKey === right.session.cacheKey &&
388
+ left.router === right.router && left.access === right.access &&
389
+ left.taskProvider === right.taskProvider && left.taskArtifactProvider === right.taskArtifactProvider &&
390
+ left.taskName === right.taskName && left.retryTaskName === right.retryTaskName &&
391
+ left.taskIdempotencyKey === right.taskIdempotencyKey;
392
+ }
148
393
 
149
- isImporting = false;
150
- onSuccess?.({ succeeded: succeededCount, failed: failedRecords.length });
394
+ async function retryFailedRows(): Promise<void> {
395
+ const receipt = retryReceipt;
396
+ const parentTaskId = ownedTaskId;
397
+ if (!open || !allowed || !receipt || !parentTaskId || !retryTaskName?.trim()
398
+ || !activeTaskProvider || durableSubmitAttempt || taskQuery.isError) return;
399
+ const origin = scope;
400
+ const token = parseToken;
401
+ const attempt = {};
402
+ durableSubmitAttempt = attempt;
403
+ importError = null;
404
+ try {
405
+ // 同一父任务和不可变回执始终使用同一键;未知提交结果不得转成新写入。
406
+ const handle = await submitTask.mutation.mutateAsync({
407
+ taskName: retryTaskName.trim(),
408
+ taskProvider: activeTaskProvider,
409
+ successNotification: false,
410
+ errorNotification: false,
411
+ options: {
412
+ idempotencyKey: JSON.stringify(['import-failed-rows', parentTaskId, receipt.receiptId]),
413
+ body: {
414
+ protocolVersion: 1,
415
+ operation: 'retry-failed-rows',
416
+ resource: binding.resource.name,
417
+ parentTaskId,
418
+ receiptId: receipt.receiptId,
419
+ rows: [...receipt.rows].sort((a, b) => a - b),
420
+ },
421
+ ...(binding.meta === undefined ? {} : { meta: binding.meta }),
422
+ },
423
+ });
424
+ if (durableSubmitAttempt !== attempt || !current(origin, token)) return;
425
+ if (handle.id === parentTaskId) throw new Error('Retry must create a child task');
426
+ durableTaskId = handle.id;
427
+ durableTaskScope = scope;
428
+ durableHandledTaskId = undefined;
429
+ durablePolling = true;
430
+ importResult = null;
431
+ try { void Promise.resolve(onTaskSubmitted?.(handle.id)).catch(() => {}); }
432
+ catch { /* 通知失败不重复提交。 */ }
433
+ } catch {
434
+ if (durableSubmitAttempt === attempt && current(origin, token)) importError = i18n.t('import.taskUnconfirmed');
435
+ } finally {
436
+ if (durableSubmitAttempt === attempt) durableSubmitAttempt = undefined;
437
+ }
151
438
  }
152
439
 
153
440
  function downloadErrors() {
@@ -161,22 +448,88 @@
161
448
  const url = URL.createObjectURL(blob);
162
449
  const link = document.createElement('a');
163
450
  link.href = url;
164
- link.download = `${resourceName}-import-errors.csv`;
165
- link.click();
166
- URL.revokeObjectURL(url);
451
+ const safeResourceName = resourceName.replace(/[^A-Za-z0-9._-]+/g, '-')
452
+ .replace(/^[._-]+/, '').slice(0, 100).replace(/[._-]+$/, '') || 'resource';
453
+ link.download = `${safeResourceName}-import-errors.csv`;
454
+ try {
455
+ link.click();
456
+ } finally {
457
+ // 浏览器需要在 click 返回后仍能读取对象 URL,延迟回收避免空下载。
458
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
459
+ }
167
460
  }
168
461
 
169
462
  function reset() {
463
+ artifactUploadController?.abort();
464
+ artifactUploadController = undefined;
465
+ parseToken += 1;
170
466
  currentStep = 1;
171
467
  fileName = '';
468
+ selectedFile = null;
172
469
  rawHeaders = [];
173
470
  rawRows = [];
174
471
  columnMapping = {};
175
- processedCount = 0;
176
472
  succeededCount = 0;
177
473
  failedRecords = [];
178
- isImporting = false;
474
+ importResult = null;
475
+ fileError = null;
476
+ importError = null;
477
+ cancelled = false;
478
+ durableSubmitAttempt = undefined;
479
+ durableHandledTaskId = undefined;
480
+ durablePolling = true;
481
+ importer.reset();
482
+ }
483
+
484
+ function cancelImport() {
485
+ if (durableMode && durableTaskId && durableActive) return;
486
+ if (!isImporting) return;
487
+ // 废弃 UI 回执与核心执行令牌;已经发出的请求并不因此回滚。
488
+ parseToken++;
489
+ artifactUploadController?.abort();
490
+ artifactUploadController = undefined;
491
+ durableSubmitAttempt = undefined;
492
+ importer.reset();
493
+ cancelled = true;
494
+ importResult = null;
495
+ importError = null;
179
496
  }
497
+
498
+ let observedScope = untrack(() => scope);
499
+ let observedOpen = untrack(() => open);
500
+ $effect.pre(() => {
501
+ const changed = !sameScope(observedScope, scope) || (observedScope.allowed && !allowed);
502
+ if (changed) {
503
+ durableTaskId = undefined;
504
+ durableTaskScope = undefined;
505
+ durableHandledTaskId = undefined;
506
+ }
507
+ if (changed || observedOpen !== open) untrack(reset);
508
+ observedScope = scope;
509
+ observedOpen = open;
510
+ });
511
+ $effect(() => {
512
+ const task = taskQuery.data;
513
+ if (!open || !allowed || !durableTaskId || !task || durableHandledTaskId === durableTaskId) return;
514
+ if (!['completed', 'failed', 'cancelled'].includes(durableStatus)) return;
515
+ durableHandledTaskId = durableTaskId;
516
+ durablePolling = false;
517
+ if (durableStatus === 'completed') {
518
+ const result = snapshotImportTaskResult(task.result ?? task.result_data);
519
+ if (result) {
520
+ succeededCount = result.succeeded;
521
+ importResult = { succeeded: result.succeeded, failed: result.failed };
522
+ try { void Promise.resolve(onSuccess?.(importResult)).catch(() => {}); }
523
+ catch { /* 任务完成通知失败不改变服务端任务结果。 */ }
524
+ } else {
525
+ importError = i18n.t('import.invalidTaskResult');
526
+ }
527
+ } else if (durableStatus === 'failed' || durableStatus === 'cancelled') {
528
+ importError = i18n.t('common.operationFailed');
529
+ importResult = null;
530
+ }
531
+ });
532
+ $effect(() => () => { mounted = false; parseToken += 1; artifactUploadController?.abort(); });
180
533
  </script>
181
534
 
182
535
  {#if open}
@@ -199,7 +552,44 @@
199
552
  </div>
200
553
 
201
554
  <!-- Step 1: Upload -->
202
- {#if currentStep === 1}
555
+ {#if !allowed}
556
+ <p role="status">{i18n.t('common.operationFailed', { defaultValue: 'Import is unavailable.' })}</p>
557
+ {/if}
558
+ {#if durableMode && (durableTaskId || durableSubmitAttempt || currentStep === 3)}
559
+ {#if durableTaskId}
560
+ <p>{durableTaskId}</p>
561
+ {#if taskQuery.isError}
562
+ <p role="alert">{i18n.t('task.fetchFailed')}</p>
563
+ <Button type="button" onclick={() => taskQuery.refetch()}>{i18n.t('common.retry')}</Button>
564
+ {:else if taskQuery.data && allowed}
565
+ <TaskStatusBadge status={taskQuery.data.status ?? 'pending'} />
566
+ {@const progress = resolveTaskProgress(taskQuery.data)}
567
+ {#if progress !== undefined}
568
+ <Progress value={progress} />
569
+ {/if}
570
+ {#if importResult}
571
+ <p role="status">{i18n.t('common.succeeded')}: {importResult.succeeded};
572
+ {i18n.t('common.failed')}: {importResult.failed}</p>
573
+ {/if}
574
+ {#if retryReceipt && retryTaskName?.trim()}
575
+ <Button type="button" disabled={!allowed || !!durableSubmitAttempt}
576
+ onclick={retryFailedRows}><RotateCw aria-hidden="true" />{i18n.t('import.retryFailedRows')}</Button>
577
+ {/if}
578
+ {#if activeTaskProvider?.cancel && canCancelTask(taskQuery.data)}
579
+ <CancelTaskButton taskId={durableTaskId} taskProvider={activeTaskProvider}
580
+ disabled={!allowed || !open}
581
+ onSuccess={() => { void taskQuery.refetch(); }}
582
+ onError={() => { importError = i18n.t('common.operationFailed'); }} />
583
+ {/if}
584
+ {:else}
585
+ <p role="status">{i18n.t('common.loading')}</p>
586
+ {/if}
587
+ {:else if durableSubmitAttempt}
588
+ <p role="status">{i18n.t('common.processing')}</p>
589
+ {/if}
590
+ {#if importError}<p role="alert">{importError}</p>{/if}
591
+ <Button type="button" onclick={() => { open = false; }}>{i18n.t('common.close')}</Button>
592
+ {:else if currentStep === 1}
203
593
  <div
204
594
  class="svadmin-u-65935df577ba svadmin-u-a29b7a649c77 svadmin-u-c9ed8c5f79ae svadmin-u-8b9d5d768973 svadmin-u-a217b4eaa918 svadmin-u-845f53365c8d svadmin-u-ca6bf63030aa svadmin-u-ceb69a6b0e5f svadmin-u-34516836730d svadmin-u-967d113a1451"
205
595
  ondragover={(e) => e.preventDefault()}
@@ -218,6 +608,7 @@
218
608
  <input
219
609
  type="file"
220
610
  accept=".csv,.json"
611
+ disabled={!allowed}
221
612
  class="svadmin-u-99d72c7fc3e2"
222
613
  onchange={(e) => {
223
614
  const target = e.currentTarget;
@@ -228,12 +619,16 @@
228
619
  {i18n.t('common.selectFile', { defaultValue: 'Browse File' })}
229
620
  </span>
230
621
  </label>
622
+ {#if fileError}
623
+ <p role="alert">{fileError}</p>
624
+ {/if}
231
625
  </div>
232
626
  {/if}
233
627
 
234
628
  <!-- Step 2: Column Mapping -->
235
- {#if currentStep === 2}
629
+ {#if currentStep === 2 && !durableTaskId}
236
630
  <div class="svadmin-u-3e7ce58d64fa">
631
+ {#if fileError}<p role="alert">{fileError}</p>{/if}
237
632
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-359090c2d529 svadmin-u-bfa603190748 svadmin-u-2859c861d7de svadmin-u-9fe52d5d506c svadmin-u-5f22e64f2282 svadmin-u-ca6bcd4b6f3f svadmin-u-05faf5c801ff">
238
633
  <span>{i18n.t('common.detectedRows', { defaultValue: 'Detected' })}: <strong class="svadmin-u-d4108abe6359">{rawRows.length}</strong> {i18n.t('common.records', { defaultValue: 'records' })}</span>
239
634
  <Badge variant="secondary" class="svadmin-u-d058ca6de60f svadmin-u-0e65706bcccd">{fileName}</Badge>
@@ -252,8 +647,13 @@
252
647
  </div>
253
648
  <div class="svadmin-u-74b2435a1d40 svadmin-u-012fbd121f37">
254
649
  <select
650
+ aria-label={`Map ${header}`}
255
651
  class="svadmin-u-ed8a5df7b2fb svadmin-u-6da6a3c3f741 svadmin-u-421ac2be5045 svadmin-u-ca6bcd4b6f3f svadmin-u-e5795dad4d22 svadmin-u-e6f9e383a762 svadmin-u-d5eab218aa34 svadmin-u-359090c2d529 svadmin-u-f10f771f87e9 svadmin-u-3e94a98e1466 svadmin-u-9c1295a6914a"
256
- bind:value={columnMapping[header]}
652
+ value={columnMapping[header]}
653
+ onchange={(event) => {
654
+ columnMapping = { ...columnMapping, [header]: event.currentTarget.value };
655
+ fileError = null;
656
+ }}
257
657
  >
258
658
  <option value="">— {i18n.t('common.ignoreColumn', { defaultValue: 'Ignore' })} —</option>
259
659
  {#each availableFields as field (field.key)}
@@ -272,7 +672,7 @@
272
672
  <ArrowLeft class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-618162408e7a" />
273
673
  {i18n.t('common.back', { defaultValue: 'Back' })}
274
674
  </Button>
275
- <Button size="sm" onclick={startImport}>
675
+ <Button size="sm" disabled={!allowed || isImporting} onclick={startImport}>
276
676
  {i18n.t('common.startImport', { defaultValue: 'Start Import' })}
277
677
  <ArrowRight class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-f58b02572ab2" />
278
678
  </Button>
@@ -281,16 +681,19 @@
281
681
  {/if}
282
682
 
283
683
  <!-- Step 3: Progress & Summary -->
284
- {#if currentStep === 3}
684
+ {#if currentStep === 3 && !durableMode}
285
685
  <div class="svadmin-u-b43b4c086d9a svadmin-u-03b4dd7f172b">
286
686
  <div class="svadmin-u-6f7e013d6499">
287
687
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-359090c2d529">
288
- <span class="svadmin-u-bfa603190748">{isImporting ? i18n.t('common.importing', { defaultValue: 'Importing...' }) : i18n.t('common.completed', { defaultValue: 'Completed' })}</span>
289
- <span class="svadmin-u-e83a7042bc91 svadmin-u-d4108abe6359 svadmin-u-3032cae0badb">{progressPercent}%</span>
688
+ <span class="svadmin-u-bfa603190748">{cancelled ? i18n.t('import.stopped') : isImporting ? i18n.t('common.importing', { defaultValue: 'Importing...' }) : importError ? i18n.t('common.operationFailed', { defaultValue: 'Import failed' }) : i18n.t('common.completed', { defaultValue: 'Completed' })}</span>
689
+ {#if !cancelled}<span class="svadmin-u-e83a7042bc91 svadmin-u-d4108abe6359 svadmin-u-3032cae0badb">{progressPercent}%</span>{/if}
290
690
  </div>
291
- <Progress value={progressPercent} class="svadmin-u-2f2a842e50fa" />
691
+ {#if !cancelled}<Progress value={progressPercent} class="svadmin-u-2f2a842e50fa" />{/if}
292
692
  </div>
293
693
 
694
+ {#if cancelled}
695
+ <p role="status">{i18n.t('import.stoppedHint')}</p>
696
+ {:else}
294
697
  <div class="svadmin-u-f3c543ad5fe9 svadmin-u-8e75e3db482b svadmin-u-1004c0c3954c svadmin-u-ca6bf63030aa">
295
698
  <div class="svadmin-u-eb6e8b881acd svadmin-u-5f22e64f2282 svadmin-u-17a9f7af2265 svadmin-u-ca6bcd4b6f3f svadmin-u-95b7dea5a67f">
296
699
  <span class="svadmin-u-359090c2d529 svadmin-u-bfa603190748 svadmin-u-0214b4b355d1">{i18n.t('common.succeeded', { defaultValue: 'Succeeded' })}</span>
@@ -301,6 +704,11 @@
301
704
  <span class="svadmin-u-d5c9b0001e7e svadmin-u-69450ef1487e svadmin-u-811148b13d1e svadmin-u-3032cae0badb">{failedRecords.length}</span>
302
705
  </div>
303
706
  </div>
707
+ {/if}
708
+
709
+ {#if importError}
710
+ <p role="alert">{importError}</p>
711
+ {/if}
304
712
 
305
713
  {#if failedRecords.length > 0}
306
714
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-eb6e8b881acd svadmin-u-5f22e64f2282 svadmin-u-7a0854fdbc30 svadmin-u-ca6bcd4b6f3f svadmin-u-e09a917c4c3b svadmin-u-359090c2d529">
@@ -324,6 +732,7 @@
324
732
  {i18n.t('common.close', { defaultValue: 'Close' })}
325
733
  </Button>
326
734
  {:else}
735
+ <Button variant="outline" size="sm" onclick={cancelImport}>{i18n.t('common.cancel')}</Button>
327
736
  <Button size="sm" disabled class="svadmin-u-77a2a20e90d4">
328
737
  <Loader2 class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-afbdd13a380e" />
329
738
  {i18n.t('common.processing', { defaultValue: 'Processing...' })}