@svadmin/ui 0.73.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 (295) hide show
  1. package/README.md +53 -28
  2. package/dist/app.css +3733 -4119
  3. package/dist/app.theme.css +3733 -4119
  4. package/dist/component-registry.d.ts +14977 -0
  5. package/dist/component-registry.js +216 -0
  6. package/dist/components/AboutSettings.svelte +7 -1
  7. package/dist/components/AboutSettings.svelte.d.ts +4 -1
  8. package/dist/components/ActivityFeed.svelte +9 -3
  9. package/dist/components/ActivityFeed.svelte.d.ts +3 -0
  10. package/dist/components/AdminApp.svelte +19 -10
  11. package/dist/components/AdminApp.svelte.d.ts +6 -0
  12. package/dist/components/ApiSettings.svelte +41 -5
  13. package/dist/components/ApprovalCenter.svelte +283 -0
  14. package/dist/components/ApprovalCenter.svelte.d.ts +10 -0
  15. package/dist/components/AuditLogViewer.svelte +3 -1
  16. package/dist/components/AutoForm.svelte +33 -3
  17. package/dist/components/AutoForm.svelte.d.ts +1 -0
  18. package/dist/components/AutoTable.svelte +610 -47
  19. package/dist/components/AutoTable.svelte.d.ts +17 -1
  20. package/dist/components/BoundRecordDetailDrawer.svelte +9 -0
  21. package/dist/components/BoundRecordDetailDrawer.svelte.d.ts +2 -0
  22. package/dist/components/Breadcrumbs.svelte +11 -1
  23. package/dist/components/CancelTaskButton.svelte +12 -42
  24. package/dist/components/ColorPicker.svelte +73 -0
  25. package/dist/components/ColorPicker.svelte.d.ts +15 -0
  26. package/dist/components/ComboboxField.svelte +114 -27
  27. package/dist/components/ComboboxField.svelte.d.ts +1 -0
  28. package/dist/components/DashboardView.svelte +99 -0
  29. package/dist/components/DashboardView.svelte.d.ts +14 -0
  30. package/dist/components/DatePicker.svelte +10 -0
  31. package/dist/components/DatePicker.svelte.d.ts +6 -0
  32. package/dist/components/DateRangeInput.svelte +85 -5
  33. package/dist/components/DateRangeInput.svelte.d.ts +10 -0
  34. package/dist/components/DateRangePicker.svelte +10 -0
  35. package/dist/components/DateRangePicker.svelte.d.ts +6 -0
  36. package/dist/components/DateTimeInput.svelte +74 -5
  37. package/dist/components/DateTimeInput.svelte.d.ts +8 -0
  38. package/dist/components/DateTimePicker.svelte +10 -0
  39. package/dist/components/DateTimePicker.svelte.d.ts +6 -0
  40. package/dist/components/DevTools.svelte +34 -26
  41. package/dist/components/DevframePanel.svelte +123 -0
  42. package/dist/components/DevframePanel.svelte.d.ts +3 -0
  43. package/dist/components/ErrorSummary.svelte +52 -0
  44. package/dist/components/ErrorSummary.svelte.d.ts +14 -0
  45. package/dist/components/FieldRenderer.svelte +82 -81
  46. package/dist/components/FileUpload.svelte +214 -52
  47. package/dist/components/FileUpload.svelte.d.ts +11 -0
  48. package/dist/components/FilterBuilder.svelte +88 -66
  49. package/dist/components/FilterCollectionInput.svelte +80 -0
  50. package/dist/components/FilterCollectionInput.svelte.d.ts +10 -0
  51. package/dist/components/GanttChart.svelte +66 -14
  52. package/dist/components/GanttChart.svelte.d.ts +6 -0
  53. package/dist/components/Header.svelte +18 -2
  54. package/dist/components/ImportWizard.svelte +339 -17
  55. package/dist/components/ImportWizard.svelte.d.ts +11 -0
  56. package/dist/components/JsonSchemaForm.svelte +120 -52
  57. package/dist/components/JsonSchemaForm.svelte.d.ts +7 -1
  58. package/dist/components/KanbanBoard.svelte +195 -40
  59. package/dist/components/KanbanBoard.svelte.d.ts +9 -2
  60. package/dist/components/Layout.svelte +4 -2
  61. package/dist/components/LazyAutoForm.svelte +28 -0
  62. package/dist/components/LazyAutoForm.svelte.d.ts +8 -0
  63. package/dist/components/LazyAutoTable.svelte +28 -0
  64. package/dist/components/LazyAutoTable.svelte.d.ts +8 -0
  65. package/dist/components/LazySettingsPage.svelte +28 -0
  66. package/dist/components/LazySettingsPage.svelte.d.ts +8 -0
  67. package/dist/components/LazyShowPage.svelte +28 -0
  68. package/dist/components/LazyShowPage.svelte.d.ts +8 -0
  69. package/dist/components/ListPage.svelte +5 -1
  70. package/dist/components/ListPage.svelte.d.ts +3 -0
  71. package/dist/components/LoginPage.svelte +1 -1
  72. package/dist/components/MultiTabKeepAlive.svelte +11 -6
  73. package/dist/components/NotificationsSettings.svelte +69 -2
  74. package/dist/components/NotificationsSettings.svelte.d.ts +25 -1
  75. package/dist/components/PermissionMatrix.svelte +20 -1
  76. package/dist/components/PivotExportButton.svelte +157 -0
  77. package/dist/components/PivotExportButton.svelte.d.ts +16 -0
  78. package/dist/components/PivotTable.svelte +229 -74
  79. package/dist/components/PivotTable.svelte.d.ts +19 -2
  80. package/dist/components/RadioGroup.svelte +121 -0
  81. package/dist/components/RadioGroup.svelte.d.ts +43 -0
  82. package/dist/components/RangeSlider.svelte +127 -0
  83. package/dist/components/RangeSlider.svelte.d.ts +22 -0
  84. package/dist/components/Rate.svelte +121 -0
  85. package/dist/components/Rate.svelte.d.ts +16 -0
  86. package/dist/components/RecordDetailDrawer.svelte +4 -2
  87. package/dist/components/RecordDetailDrawer.svelte.d.ts +2 -0
  88. package/dist/components/RelationPicker.svelte +341 -0
  89. package/dist/components/RelationPicker.svelte.d.ts +26 -0
  90. package/dist/components/ResourceOperationsPage.svelte +18 -18
  91. package/dist/components/ResourceOperationsPage.svelte.d.ts +2 -2
  92. package/dist/components/RetryTaskButton.svelte +12 -42
  93. package/dist/components/SecuritySettings.svelte +29 -5
  94. package/dist/components/SegmentedControl.svelte +145 -0
  95. package/dist/components/SegmentedControl.svelte.d.ts +38 -0
  96. package/dist/components/SettingsPage.svelte +3 -1
  97. package/dist/components/SettingsPage.svelte.d.ts +1 -0
  98. package/dist/components/ShowPage.svelte +18 -3
  99. package/dist/components/ShowPage.svelte.d.ts +3 -0
  100. package/dist/components/Sidebar.svelte +2 -2
  101. package/dist/components/Slider.svelte +100 -0
  102. package/dist/components/Slider.svelte.d.ts +19 -0
  103. package/dist/components/SpreadsheetView.svelte +1213 -95
  104. package/dist/components/SpreadsheetView.svelte.d.ts +19 -0
  105. package/dist/components/TagsInput.svelte +178 -0
  106. package/dist/components/TagsInput.svelte.d.ts +17 -0
  107. package/dist/components/TimePicker.svelte +10 -0
  108. package/dist/components/TimePicker.svelte.d.ts +6 -0
  109. package/dist/components/TreeSelect.svelte +267 -53
  110. package/dist/components/TreeSelect.svelte.d.ts +3 -0
  111. package/dist/components/TreeTable.svelte +484 -82
  112. package/dist/components/TreeTable.svelte.d.ts +24 -1
  113. package/dist/components/TwoFactorAuthPage.svelte +85 -48
  114. package/dist/components/TwoFactorAuthPage.svelte.d.ts +12 -1
  115. package/dist/components/VirtualTable.svelte +116 -20
  116. package/dist/components/VirtualTable.svelte.d.ts +5 -0
  117. package/dist/components/account/CompanyProfilePage.svelte +74 -38
  118. package/dist/components/account/GetStartedPage.svelte +2 -1
  119. package/dist/components/account/ImportMembersPage.svelte +22 -167
  120. package/dist/components/account/ImportMembersPage.svelte.d.ts +4 -1
  121. package/dist/components/account/MemberDirectory.svelte +156 -0
  122. package/dist/components/account/MemberDirectory.svelte.d.ts +19 -0
  123. package/dist/components/account/MembersStarterPage.svelte +9 -55
  124. package/dist/components/account/MembersStarterPage.svelte.d.ts +5 -1
  125. package/dist/components/account/SecurityLogPage.svelte +4 -1
  126. package/dist/components/account/SettingsPlainPage.svelte +7 -38
  127. package/dist/components/account/SettingsPlainPage.svelte.d.ts +4 -1
  128. package/dist/components/account/SettingsSidebarPage.svelte +7 -20
  129. package/dist/components/account/TeamMembersPage.svelte +4 -20
  130. package/dist/components/account/TeamMembersPage.svelte.d.ts +5 -1
  131. package/dist/components/account/UserProfilePage.svelte +3 -25
  132. package/dist/components/account/UserProfilePage.svelte.d.ts +17 -2
  133. package/dist/components/buttons/ExportButton.svelte +56 -12
  134. package/dist/components/buttons/ExportButton.svelte.d.ts +11 -0
  135. package/dist/components/content/ContentPageHeader.svelte +1 -1
  136. package/dist/components/content/ContentPageShell.svelte +1 -1
  137. package/dist/components/content/MetricBlock.svelte +1 -1
  138. package/dist/components/content/PageToolbar.svelte +1 -1
  139. package/dist/components/content/ReferenceNotice.svelte +5 -0
  140. package/dist/components/content/ReferenceNotice.svelte.d.ts +18 -0
  141. package/dist/components/content/SectionHeader.svelte +1 -1
  142. package/dist/components/content/SettingsFieldRow.svelte +1 -1
  143. package/dist/components/content/SettingsGroup.svelte +1 -1
  144. package/dist/components/content/StatusBadge.svelte +1 -1
  145. package/dist/components/content/WorkspaceLayout.svelte +1 -1
  146. package/dist/components/export-reference.d.ts +2 -0
  147. package/dist/components/export-reference.js +9 -0
  148. package/dist/components/fieldComponentMap.js +2 -5
  149. package/dist/components/fields/DateField.svelte +12 -13
  150. package/dist/components/fields/date-display.d.ts +6 -0
  151. package/dist/components/fields/date-display.js +24 -0
  152. package/dist/components/network/TeamCrewTablePage.svelte +6 -1
  153. package/dist/components/network/UserCardsNFTPage.svelte +2 -0
  154. package/dist/components/profile/PublicProfilePage.svelte +2 -0
  155. package/dist/components/saved-list-views.d.ts +74 -1
  156. package/dist/components/saved-list-views.js +191 -3
  157. package/dist/components/table-contract.d.ts +12 -1
  158. package/dist/components/task-action.svelte.d.ts +13 -0
  159. package/dist/components/task-action.svelte.js +73 -0
  160. package/dist/components/ui/DesignSystemShowcase.svelte +164 -0
  161. package/dist/components/ui/DesignSystemShowcase.svelte.d.ts +3 -0
  162. package/dist/components/ui/badge/badge-variants.d.ts +3 -2
  163. package/dist/components/ui/badge/badge-variants.js +2 -3
  164. package/dist/components/ui/badge/badge.svelte +3 -0
  165. package/dist/components/ui/badge/badge.svelte.d.ts +1 -0
  166. package/dist/components/ui/button/button-variants.d.ts +5 -4
  167. package/dist/components/ui/button/button-variants.js +7 -5
  168. package/dist/components/ui/checkbox/checkbox.svelte +5 -1
  169. package/dist/components/ui/checkbox/checkbox.svelte.d.ts +5 -1
  170. package/dist/components/ui/input/input.svelte +7 -2
  171. package/dist/components/ui/input/input.svelte.d.ts +4 -1
  172. package/dist/components/ui/select/select.svelte +4 -1
  173. package/dist/components/ui/select/select.svelte.d.ts +2 -1
  174. package/dist/components/ui/textarea/textarea.svelte +2 -2
  175. package/dist/components.css +33 -2
  176. package/dist/date-input-policy.d.ts +4 -0
  177. package/dist/date-input-policy.js +35 -0
  178. package/dist/design-system-components.d.ts +100 -0
  179. package/dist/design-system-components.js +101 -0
  180. package/dist/design-system.js +1 -1
  181. package/dist/devtools-bridge.d.ts +80 -0
  182. package/dist/devtools-bridge.js +92 -0
  183. package/dist/index.d.ts +37 -2
  184. package/dist/index.js +23 -2
  185. package/dist/recipes.d.ts +960 -4
  186. package/dist/recipes.js +264 -4
  187. package/dist/tree-table-model.d.ts +24 -0
  188. package/dist/tree-table-model.js +111 -0
  189. package/package.json +22 -10
  190. package/dist/styled-system/css/conditions.js +0 -36
  191. package/dist/styled-system/css/css.d.ts +0 -22
  192. package/dist/styled-system/css/css.js +0 -45
  193. package/dist/styled-system/css/cva.d.ts +0 -6
  194. package/dist/styled-system/css/cva.js +0 -87
  195. package/dist/styled-system/css/cx.d.ts +0 -5
  196. package/dist/styled-system/css/cx.js +0 -15
  197. package/dist/styled-system/css/index.d.ts +0 -5
  198. package/dist/styled-system/css/index.js +0 -4
  199. package/dist/styled-system/css/sva.d.ts +0 -4
  200. package/dist/styled-system/css/sva.js +0 -46
  201. package/dist/styled-system/helpers.js +0 -316
  202. package/dist/styled-system/patterns/aspect-ratio.d.ts +0 -20
  203. package/dist/styled-system/patterns/aspect-ratio.js +0 -38
  204. package/dist/styled-system/patterns/bleed.d.ts +0 -21
  205. package/dist/styled-system/patterns/bleed.js +0 -24
  206. package/dist/styled-system/patterns/box.d.ts +0 -20
  207. package/dist/styled-system/patterns/box.js +0 -15
  208. package/dist/styled-system/patterns/center.d.ts +0 -20
  209. package/dist/styled-system/patterns/center.js +0 -21
  210. package/dist/styled-system/patterns/circle.d.ts +0 -20
  211. package/dist/styled-system/patterns/circle.js +0 -25
  212. package/dist/styled-system/patterns/container.d.ts +0 -20
  213. package/dist/styled-system/patterns/container.js +0 -21
  214. package/dist/styled-system/patterns/cq.d.ts +0 -21
  215. package/dist/styled-system/patterns/cq.js +0 -21
  216. package/dist/styled-system/patterns/divider.d.ts +0 -22
  217. package/dist/styled-system/patterns/divider.js +0 -25
  218. package/dist/styled-system/patterns/flex.d.ts +0 -26
  219. package/dist/styled-system/patterns/flex.js +0 -26
  220. package/dist/styled-system/patterns/float.d.ts +0 -23
  221. package/dist/styled-system/patterns/float.js +0 -52
  222. package/dist/styled-system/patterns/grid-item.d.ts +0 -25
  223. package/dist/styled-system/patterns/grid-item.js +0 -25
  224. package/dist/styled-system/patterns/grid.d.ts +0 -24
  225. package/dist/styled-system/patterns/grid.js +0 -27
  226. package/dist/styled-system/patterns/hstack.d.ts +0 -21
  227. package/dist/styled-system/patterns/hstack.js +0 -24
  228. package/dist/styled-system/patterns/index.d.ts +0 -21
  229. package/dist/styled-system/patterns/index.js +0 -20
  230. package/dist/styled-system/patterns/link-overlay.d.ts +0 -20
  231. package/dist/styled-system/patterns/link-overlay.js +0 -24
  232. package/dist/styled-system/patterns/spacer.d.ts +0 -20
  233. package/dist/styled-system/patterns/spacer.js +0 -25
  234. package/dist/styled-system/patterns/square.d.ts +0 -20
  235. package/dist/styled-system/patterns/square.js +0 -24
  236. package/dist/styled-system/patterns/stack.d.ts +0 -23
  237. package/dist/styled-system/patterns/stack.js +0 -24
  238. package/dist/styled-system/patterns/visually-hidden.d.ts +0 -20
  239. package/dist/styled-system/patterns/visually-hidden.js +0 -18
  240. package/dist/styled-system/patterns/vstack.d.ts +0 -21
  241. package/dist/styled-system/patterns/vstack.js +0 -24
  242. package/dist/styled-system/patterns/wrap.d.ts +0 -24
  243. package/dist/styled-system/patterns/wrap.js +0 -25
  244. package/dist/styled-system/recipes/content-header.d.ts +0 -31
  245. package/dist/styled-system/recipes/content-header.js +0 -65
  246. package/dist/styled-system/recipes/content-page.d.ts +0 -34
  247. package/dist/styled-system/recipes/content-page.js +0 -43
  248. package/dist/styled-system/recipes/create-recipe.js +0 -82
  249. package/dist/styled-system/recipes/index.d.ts +0 -17
  250. package/dist/styled-system/recipes/index.js +0 -16
  251. package/dist/styled-system/recipes/metric-block.d.ts +0 -34
  252. package/dist/styled-system/recipes/metric-block.js +0 -76
  253. package/dist/styled-system/recipes/product-list.d.ts +0 -31
  254. package/dist/styled-system/recipes/product-list.js +0 -73
  255. package/dist/styled-system/recipes/product-section.d.ts +0 -31
  256. package/dist/styled-system/recipes/product-section.js +0 -49
  257. package/dist/styled-system/recipes/product-settings-row.d.ts +0 -34
  258. package/dist/styled-system/recipes/product-settings-row.js +0 -58
  259. package/dist/styled-system/recipes/product-settings.d.ts +0 -31
  260. package/dist/styled-system/recipes/product-settings.js +0 -57
  261. package/dist/styled-system/recipes/product-status.d.ts +0 -34
  262. package/dist/styled-system/recipes/product-status.js +0 -45
  263. package/dist/styled-system/recipes/product-toolbar.d.ts +0 -31
  264. package/dist/styled-system/recipes/product-toolbar.js +0 -41
  265. package/dist/styled-system/recipes/product-workspace.d.ts +0 -34
  266. package/dist/styled-system/recipes/product-workspace.js +0 -58
  267. package/dist/styled-system/recipes/surface-metric.d.ts +0 -38
  268. package/dist/styled-system/recipes/surface-metric.js +0 -63
  269. package/dist/styled-system/recipes/surface-table.d.ts +0 -34
  270. package/dist/styled-system/recipes/surface-table.js +0 -62
  271. package/dist/styled-system/recipes/ui-badge.d.ts +0 -31
  272. package/dist/styled-system/recipes/ui-badge.js +0 -38
  273. package/dist/styled-system/recipes/ui-button.d.ts +0 -32
  274. package/dist/styled-system/recipes/ui-button.js +0 -43
  275. package/dist/styled-system/recipes/ui-input.d.ts +0 -31
  276. package/dist/styled-system/recipes/ui-input.js +0 -49
  277. package/dist/styled-system/recipes/ui-textarea.d.ts +0 -31
  278. package/dist/styled-system/recipes/ui-textarea.js +0 -24
  279. package/dist/styled-system/styles.css +0 -1257
  280. package/dist/styled-system/tokens/index.d.ts +0 -9
  281. package/dist/styled-system/tokens/index.js +0 -268
  282. package/dist/styled-system/tokens/tokens.d.ts +0 -36
  283. package/dist/styled-system/types/composition.d.ts +0 -227
  284. package/dist/styled-system/types/conditions.d.ts +0 -236
  285. package/dist/styled-system/types/csstype.d.ts +0 -22570
  286. package/dist/styled-system/types/index.d.ts +0 -6
  287. package/dist/styled-system/types/parts.d.ts +0 -8
  288. package/dist/styled-system/types/pattern.d.ts +0 -78
  289. package/dist/styled-system/types/prop-type.d.ts +0 -223
  290. package/dist/styled-system/types/recipe.d.ts +0 -181
  291. package/dist/styled-system/types/selectors.d.ts +0 -59
  292. package/dist/styled-system/types/static-css.d.ts +0 -56
  293. package/dist/styled-system/types/style-props.d.ts +0 -8088
  294. package/dist/styled-system/types/system-types.d.ts +0 -151
  295. package/dist/styles/recipes.css +0 -1262
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
- import { onDestroy } from 'svelte';
2
+ import { onDestroy, untrack } from 'svelte';
3
3
  import { X, Upload, RotateCw, Ban } from '@lucide/svelte';
4
4
  import { Button } from './ui/button/index.js';
5
+ import { useTranslation } from '@svadmin/core/i18n';
5
6
 
6
7
  export type UploadItemStatus = 'queued' | 'uploading' | 'success' | 'error' | 'cancelled';
7
8
 
@@ -9,15 +10,25 @@
9
10
  id: string;
10
11
  file: File;
11
12
  status: UploadItemStatus;
12
- progress: number;
13
- url?: string;
14
- error?: string;
15
- }
13
+ progress: number;
14
+ url?: string;
15
+ error?: string;
16
+ uploadId?: string;
17
+ cleanupStatus?: 'pending' | 'success' | 'error';
18
+ }
16
19
 
17
- export interface UploadSession {
18
- signal: AbortSignal;
19
- onProgress: (progress: number) => void;
20
- }
20
+ export interface UploadSession {
21
+ signal: AbortSignal;
22
+ idempotencyKey: string;
23
+ setUploadId: (uploadId: string) => void;
24
+ onProgress: (progress: number) => void;
25
+ }
26
+
27
+ export interface UploadCancellation {
28
+ uploadId: string;
29
+ idempotencyKey: string;
30
+ reason: 'cancel' | 'remove' | 'replace' | 'scope-change' | 'unmount';
31
+ }
21
32
 
22
33
  interface Props {
23
34
  id?: string;
@@ -28,7 +39,8 @@
28
39
  maxSize?: number;
29
40
  disabled?: boolean;
30
41
  required?: boolean;
31
- upload?: (file: File, session: UploadSession) => Promise<{ url?: string } | undefined> | Promise<void>;
42
+ upload?: (file: File, session: UploadSession) => Promise<{ url?: string; uploadId?: string } | undefined> | Promise<void>;
43
+ cancelUpload?: (file: File, cancellation: UploadCancellation) => Promise<void>;
32
44
  onChange?: (items: UploadItem[]) => void;
33
45
  onReject?: (file: File, reason: string) => void;
34
46
  class?: string;
@@ -44,20 +56,89 @@
44
56
  disabled = false,
45
57
  required = false,
46
58
  upload,
59
+ cancelUpload,
47
60
  onChange,
48
61
  onReject,
49
62
  class: className = '',
50
63
  }: Props = $props();
51
64
 
65
+ const i18n = useTranslation();
52
66
  let input: HTMLInputElement | undefined = $state();
53
67
  let items = $state<UploadItem[]>([]);
68
+ let rejected = $state<{ name: string; reason: string }[]>([]);
54
69
  let sequence = 0;
55
70
  const controllers = new Map<string, AbortController>();
56
- let disposed = false;
71
+ interface UploadAttempt {
72
+ controller: AbortController;
73
+ upload: NonNullable<Props['upload']>;
74
+ cancelUpload?: Props['cancelUpload'];
75
+ epoch: number;
76
+ file: File;
77
+ idempotencyKey: string;
78
+ uploadId?: string;
79
+ reason?: UploadCancellation['reason'];
80
+ cleanupStarted?: boolean;
81
+ settled?: boolean;
82
+ }
83
+ const attempts = new Map<string, UploadAttempt>();
84
+ const latestAttempts = new Map<string, UploadAttempt>();
85
+ let scopeEpoch = 0;
86
+ let observedUpload = untrack(() => upload);
87
+ let destroyed = false;
88
+
89
+ $effect.pre(() => {
90
+ if (upload === observedUpload) return;
91
+ observedUpload = upload;
92
+ scopeEpoch += 1;
93
+ untrack(() => {
94
+ const activeIds = [...controllers.keys()];
95
+ for (const id of activeIds) retire(id, 'scope-change');
96
+ const cancelledIds = new Set(activeIds);
97
+ if (cancelledIds.size > 0) {
98
+ items = items.map(item => cancelledIds.has(item.id) && item.status === 'uploading'
99
+ ? { ...item, status: 'cancelled', error: 'Upload cancelled.' }
100
+ : item);
101
+ emitChange();
102
+ }
103
+ });
104
+ });
105
+
106
+ function retire(id: string, reason: UploadCancellation['reason'] = 'cancel'): void {
107
+ const attempt = attempts.get(id);
108
+ const controller = controllers.get(id);
109
+ controllers.delete(id);
110
+ attempts.delete(id);
111
+ if (attempt) attempt.reason = reason;
112
+ controller?.abort();
113
+ if (attempt) cleanupAttempt(id, attempt);
114
+ }
115
+
116
+ function cleanupAttempt(id: string, attempt: UploadAttempt): void {
117
+ if (!attempt.reason || attempt.cleanupStarted) return;
118
+ if (attempt?.uploadId && attempt.cancelUpload) {
119
+ attempt.cleanupStarted = true;
120
+ const publish = (cleanupStatus: NonNullable<UploadItem['cleanupStatus']>) => {
121
+ if (!destroyed && scopeEpoch === attempt.epoch && upload === attempt.upload
122
+ && latestAttempts.get(id) === attempt && items.some(item => item.id === id)) {
123
+ updateItem(id, { cleanupStatus });
124
+ }
125
+ };
126
+ publish('pending');
127
+ const cancellation: UploadCancellation = {
128
+ uploadId: attempt.uploadId,
129
+ idempotencyKey: attempt.idempotencyKey,
130
+ reason: attempt.reason,
131
+ };
132
+ const cancel = attempt.cancelUpload;
133
+ void Promise.resolve().then(() => cancel(attempt.file, cancellation))
134
+ .then(() => publish('success'), () => publish('error'));
135
+ }
136
+ }
137
+
57
138
  onDestroy(() => {
58
- disposed = true;
59
- for (const controller of controllers.values()) controller.abort();
60
- controllers.clear();
139
+ destroyed = true;
140
+ for (const id of controllers.keys()) retire(id, 'unmount');
141
+ latestAttempts.clear();
61
142
  });
62
143
 
63
144
  function emitChange(): void {
@@ -86,61 +167,127 @@
86
167
  return undefined;
87
168
  }
88
169
 
89
- function updateItem(id: string, update: Partial<UploadItem>, clear: readonly ('url' | 'error')[] = []): void {
90
- if (disposed || !items.some(item => item.id === id)) return;
170
+ const normalizedMaxFiles = $derived(
171
+ multiple ? (Number.isSafeInteger(maxFiles) && maxFiles > 0 ? maxFiles : 10) : 1,
172
+ );
173
+
174
+ type UploadItemUpdate = Partial<Omit<UploadItem, 'error' | 'url' | 'uploadId' | 'cleanupStatus'>> & {
175
+ [K in 'error' | 'url' | 'uploadId' | 'cleanupStatus']?: UploadItem[K] | undefined;
176
+ };
177
+
178
+ function updateItem(id: string, update: UploadItemUpdate): void {
91
179
  items = items.map(item => {
92
180
  if (item.id !== id) return item;
93
- const next = { ...item, ...update };
94
- if (clear.includes('error')) delete next.error;
95
- if (clear.includes('url')) delete next.url;
181
+ const { error, url, uploadId, cleanupStatus, ...required } = update;
182
+ const next: UploadItem = { ...item, ...required };
183
+ if ('error' in update && error === undefined) delete next.error;
184
+ if ('url' in update && url === undefined) delete next.url;
185
+ if ('uploadId' in update && uploadId === undefined) delete next.uploadId;
186
+ if ('cleanupStatus' in update && cleanupStatus === undefined) delete next.cleanupStatus;
187
+ if (error !== undefined) next.error = error;
188
+ if (url !== undefined) next.url = url;
189
+ if (uploadId !== undefined) next.uploadId = uploadId;
190
+ if (cleanupStatus !== undefined) next.cleanupStatus = cleanupStatus;
96
191
  return next;
97
192
  });
98
193
  emitChange();
99
194
  }
100
195
 
101
196
  async function process(item: UploadItem): Promise<void> {
102
- if (!upload || disabled || disposed || controllers.has(item.id) || !items.some(candidate => candidate.id === item.id)) return;
197
+ if (destroyed || !upload || disabled) return;
198
+ if (controllers.has(item.id) || !items.some(candidate => candidate.id === item.id)) return;
103
199
  const controller = new AbortController();
104
200
  controllers.set(item.id, controller);
105
- // 取消、替换、重试或卸载后,旧请求不再拥有更新状态的权限。
106
- const current = () => !disposed && !controller.signal.aborted && controllers.get(item.id) === controller;
107
- updateItem(item.id, { status: 'uploading', progress: 0 }, ['error', 'url']);
201
+ const attempt: UploadAttempt = {
202
+ controller, upload, epoch: scopeEpoch, file: item.file,
203
+ cancelUpload,
204
+ idempotencyKey: crypto.randomUUID(),
205
+ };
206
+ attempts.set(item.id, attempt);
207
+ latestAttempts.set(item.id, attempt);
208
+ // 每次尝试由独立控制器持有;旧回执和 finally 不得影响新尝试。
209
+ const current = () => attempts.get(item.id) === attempt
210
+ && controllers.get(item.id) === controller
211
+ && scopeEpoch === attempt.epoch && upload === attempt.upload;
212
+ const updateCurrent = (update: UploadItemUpdate) => {
213
+ if (current() && !controller.signal.aborted) updateItem(item.id, update);
214
+ };
215
+ const register = (uploadId: string) => {
216
+ if (attempt.settled) return;
217
+ if (typeof uploadId !== 'string' || !uploadId.trim() || uploadId.length > 200
218
+ || (attempt.uploadId !== undefined && attempt.uploadId !== uploadId)) throw new Error('Invalid upload session.');
219
+ attempt.uploadId = uploadId;
220
+ updateCurrent({ uploadId });
221
+ cleanupAttempt(item.id, attempt);
222
+ };
223
+ updateItem(item.id, {
224
+ status: 'uploading',
225
+ progress: 0,
226
+ error: undefined,
227
+ uploadId: undefined,
228
+ cleanupStatus: undefined,
229
+ });
108
230
  try {
109
- // onChange may synchronously unmount the component or retire this attempt.
110
- if (!current()) return;
111
- const result = await upload(item.file, {
231
+ // 宿主回调可以同步卸载或替换组件;通知返回后必须重新确认本次请求仍有效。
232
+ if (destroyed || !current() || controller.signal.aborted) return;
233
+ const result = await attempt.upload(item.file, {
112
234
  signal: controller.signal,
113
- onProgress: progress => {
114
- if (current()) updateItem(item.id, {
115
- progress: Number.isFinite(progress) ? Math.max(0, Math.min(100, Math.round(progress))) : 0,
116
- });
117
- },
235
+ idempotencyKey: attempt.idempotencyKey,
236
+ setUploadId: register,
237
+ onProgress: progress => updateCurrent({
238
+ progress: Number.isFinite(progress) ? Math.max(0, Math.min(100, Math.round(progress))) : 0,
239
+ }),
240
+ });
241
+ if (result?.uploadId !== undefined) register(result.uploadId);
242
+ const uploadId = result?.uploadId ?? attempt.uploadId;
243
+ updateCurrent({
244
+ status: 'success',
245
+ progress: 100,
246
+ ...(result?.url === undefined ? {} : { url: result.url }),
247
+ ...(uploadId === undefined ? {} : { uploadId }),
118
248
  });
119
- if (current()) updateItem(item.id, { status: 'success', progress: 100,
120
- ...(result?.url === undefined ? {} : { url: result.url }) });
121
249
  } catch (error) {
122
- if (current()) updateItem(item.id, { status: 'error',
123
- error: error instanceof Error ? error.message : 'Upload failed.' });
250
+ if (!current()) return;
251
+ updateItem(item.id, {
252
+ status: controller.signal.aborted ? 'cancelled' : 'error',
253
+ error: controller.signal.aborted ? 'Upload cancelled.' : error instanceof Error ? error.message : 'Upload failed.',
254
+ });
124
255
  } finally {
125
- if (controllers.get(item.id) === controller) controllers.delete(item.id);
256
+ attempt.settled = true;
257
+ if (current()) {
258
+ controllers.delete(item.id);
259
+ attempts.delete(item.id);
260
+ }
126
261
  }
127
262
  }
128
263
 
129
264
  function addFiles(selected: File[]): void {
130
- if (disabled || disposed) return;
131
- const available = Math.max(0, maxFiles - items.length);
132
- for (const file of selected.slice(0, multiple ? available : 1)) {
133
- // Host notifications may synchronously disable or unmount this batch.
134
- if (disabled || disposed) return;
265
+ if (destroyed || disabled) return;
266
+ const available = multiple
267
+ ? Math.max(0, normalizedMaxFiles - items.length)
268
+ : 1;
269
+ rejected = [];
270
+ if (selected.length > available) {
271
+ for (const file of selected.slice(available)) {
272
+ if (destroyed) return;
273
+ rejected = [...rejected, { name: file.name, reason: 'Maximum file count exceeded.' }];
274
+ onReject?.(file, 'Maximum file count exceeded.');
275
+ }
276
+ }
277
+ for (const file of selected.slice(0, multiple ? available : Math.min(available, 1))) {
278
+ if (destroyed) return;
135
279
  const reason = validate(file);
136
280
  if (reason) {
281
+ rejected = [...rejected, { name: file.name, reason }];
137
282
  onReject?.(file, reason);
138
283
  continue;
139
284
  }
140
285
  const item: UploadItem = { id: nextId(), file, status: 'queued', progress: 0 };
141
286
  if (!multiple) {
142
- for (const controller of controllers.values()) controller.abort();
143
- controllers.clear();
287
+ for (const previous of items) {
288
+ retire(previous.id, 'replace');
289
+ latestAttempts.delete(previous.id);
290
+ }
144
291
  }
145
292
  items = multiple ? [...items, item] : [item];
146
293
  emitChange();
@@ -152,21 +299,23 @@
152
299
  const target = event.currentTarget;
153
300
  if (!(target instanceof HTMLInputElement)) return;
154
301
  addFiles(Array.from(target.files ?? []));
302
+ // 异步模式的文件由队列持有,清空输入以允许再次选择同名文件。
303
+ if (upload) target.value = '';
155
304
  }
156
305
 
157
306
  function remove(id: string): void {
158
- if (disabled || disposed) return;
159
- controllers.get(id)?.abort();
160
- controllers.delete(id);
307
+ if (disabled) return;
308
+ retire(id, 'remove');
309
+ latestAttempts.delete(id);
161
310
  items = items.filter(item => item.id !== id);
311
+ if (input) input.value = '';
162
312
  emitChange();
163
313
  }
164
314
 
165
315
  function cancel(id: string): void {
166
316
  const controller = controllers.get(id);
167
317
  if (!controller) return;
168
- controller.abort();
169
- controllers.delete(id);
318
+ retire(id, 'cancel');
170
319
  updateItem(id, { status: 'cancelled', error: 'Upload cancelled.' });
171
320
  }
172
321
 
@@ -183,15 +332,15 @@
183
332
  </script>
184
333
 
185
334
  <div class={`svadmin-file-upload ${className}`}>
186
- <input
335
+ <input
187
336
  bind:this={input}
188
337
  type="file"
189
338
  {id}
190
- {name}
339
+ name={upload ? undefined : name}
191
340
  {accept}
192
341
  {multiple}
193
342
  disabled={disabled}
194
- {required}
343
+ required={upload ? false : required}
195
344
  class="svadmin-file-upload-input"
196
345
  onchange={handleInput}
197
346
  />
@@ -209,8 +358,19 @@
209
358
  }}
210
359
  >
211
360
  <Upload aria-hidden="true" />
212
- <span>Select or drop files</span>
361
+ <span>{i18n.t('upload.selectOrDrop')}</span>
362
+ </div>
363
+
364
+ <div role="status" aria-live="polite">
365
+ {i18n.t('upload.selectedCount', { count: String(items.length), max: String(normalizedMaxFiles) })}
213
366
  </div>
367
+ {#if rejected.length}
368
+ <ul aria-label={i18n.t('upload.rejectedFiles')}>
369
+ {#each rejected as rejection (rejection)}
370
+ <li>{rejection.name}: {rejection.reason}</li>
371
+ {/each}
372
+ </ul>
373
+ {/if}
214
374
 
215
375
  {#if items.length}
216
376
  <ul class="svadmin-file-upload-list" aria-label="Selected files">
@@ -233,6 +393,8 @@
233
393
  <X aria-hidden="true" />
234
394
  </Button>
235
395
  {#if item.error}<span role="alert">{item.error}</span>{/if}
396
+ {#if item.cleanupStatus === 'pending'}<span role="status">{i18n.t('upload.cleanupPending')}</span>{/if}
397
+ {#if item.cleanupStatus === 'error'}<span role="alert">{i18n.t('upload.cleanupFailed')}</span>{/if}
236
398
  </li>
237
399
  {/each}
238
400
  </ul>
@@ -6,11 +6,20 @@ export interface UploadItem {
6
6
  progress: number;
7
7
  url?: string;
8
8
  error?: string;
9
+ uploadId?: string;
10
+ cleanupStatus?: 'pending' | 'success' | 'error';
9
11
  }
10
12
  export interface UploadSession {
11
13
  signal: AbortSignal;
14
+ idempotencyKey: string;
15
+ setUploadId: (uploadId: string) => void;
12
16
  onProgress: (progress: number) => void;
13
17
  }
18
+ export interface UploadCancellation {
19
+ uploadId: string;
20
+ idempotencyKey: string;
21
+ reason: 'cancel' | 'remove' | 'replace' | 'scope-change' | 'unmount';
22
+ }
14
23
  interface Props {
15
24
  id?: string;
16
25
  name?: string;
@@ -22,7 +31,9 @@ interface Props {
22
31
  required?: boolean;
23
32
  upload?: (file: File, session: UploadSession) => Promise<{
24
33
  url?: string;
34
+ uploadId?: string;
25
35
  } | undefined> | Promise<void>;
36
+ cancelUpload?: (file: File, cancellation: UploadCancellation) => Promise<void>;
26
37
  onChange?: (items: UploadItem[]) => void;
27
38
  onReject?: (file: File, reason: string) => void;
28
39
  class?: string;
@@ -7,10 +7,18 @@
7
7
  import { Select } from './ui/select/index.js';
8
8
  import { useTranslation } from '@svadmin/core/i18n';
9
9
  import type { CrudOperator, FieldDefinition, Filter } from '@svadmin/core';
10
+ import { isNumericFilterField as isNumericField } from '@svadmin/core';
11
+ import FilterCollectionInput from './FilterCollectionInput.svelte';
10
12
  import { readFilterTree, compileFilterTree, filterOperators, parseFilterInput, isCollectionOperator, isNullOperator,
11
13
  FILTER_EDITOR_LIMITS, type FilterGroupNode, type FilterNode, type FilterRuleNode, type FilterEditorIssue, type FilterIssueCode } from './enterprise/filter-tree.js';
12
14
 
13
- export interface FilterRuleItem { id: string; field: string; operator: CrudOperator; value: unknown }
15
+ export interface FilterRuleItem {
16
+ id: string;
17
+ field: string;
18
+ operator: CrudOperator;
19
+ value: unknown;
20
+ }
21
+
14
22
  interface Props {
15
23
  fields?: FieldDefinition[];
16
24
  filters?: Filter[];
@@ -27,13 +35,15 @@
27
35
  const i18n = useTranslation();
28
36
  const chinese = $derived(i18n.locale.startsWith('zh'));
29
37
  const availableFields = $derived(fields.filter((field) => field.filterable !== false));
38
+ // 金额、评分等数字字段沿用 core 的输入语义;树编译器统一按 number 验证。
39
+ const editorFields = $derived(fields.map(field => isNumericField(field) ? { ...field, type: 'number' as const } : field));
30
40
  let root = $state<FilterGroupNode>({ kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false });
31
41
  let loadIssues = $state<FilterEditorIssue[]>([]);
32
42
  let attempted = $state(false);
33
43
  let nextId = 0;
34
44
  let lastInputFilters: Filter[] | undefined;
35
45
  let preserved = $state.raw<ReadonlyMap<string, Filter>>(new Map());
36
- const compilation = $derived(compileFilterTree({ ...root, operator: logicalOperator }, fields, preserved));
46
+ const compilation = $derived(compileFilterTree({ ...root, operator: logicalOperator }, editorFields, preserved));
37
47
  const issues = $derived(loadIssues.length ? loadIssues : attempted && !compilation.ok ? compilation.issues : []);
38
48
  function countNodes(node: FilterNode): number { return 1 + (node.kind === 'group' ? node.children.reduce((sum, child) => sum + countNodes(child), 0) : 0); }
39
49
  const nodeCount = $derived(countNodes(root));
@@ -43,94 +53,75 @@
43
53
  return node.kind === 'group' ? { operator: node.operator, value: node.children.map(snapshotNode) }
44
54
  : { field: node.field, operator: node.operator, value: Array.isArray(node.value) ? [...node.value] : node.value };
45
55
  }
56
+
46
57
  function cannotEdit(node: FilterRuleNode): boolean {
47
- return isCollectionOperator(node.operator) || node.value === ''
48
- || !compileFilterTree({ kind: 'group', id: 'probe', operator: 'and', children: [{ ...node, readonly: false }] }, fields).ok;
58
+ return !compileFilterTree({ kind: 'group', id: 'probe', operator: 'and', children: [node] }, editorFields).ok;
49
59
  }
50
60
  function remember(node: FilterNode, saved: Map<string, Filter>): void {
51
61
  saved.set(node.id, snapshotNode(node));
52
62
  if (node.kind === 'group') node.children.forEach(child => remember(child, saved));
53
63
  else node.readonly = cannotEdit(node);
54
64
  }
55
- function refreshReadonly(node: FilterNode): void {
56
- if (node.kind === 'group') { node.children.forEach(refreshReadonly); return; }
57
- const original = preserved.get(node.id);
58
- node.readonly = original !== undefined && JSON.stringify(original) === JSON.stringify(snapshotNode(node)) && cannotEdit(node);
59
- }
65
+
60
66
  $effect(() => {
61
- const metadata = availableFields;
62
- if (filters !== lastInputFilters) {
63
- lastInputFilters = filters;
64
- const parsed = readFilterTree(filters);
65
- if (parsed.ok) {
67
+ const input = filters;
68
+ const metadata = editorFields;
69
+ untrack(() => {
70
+ if (input !== lastInputFilters) {
71
+ lastInputFilters = input;
72
+ const parsed = readFilterTree(input);
66
73
  const saved = new Map<string, Filter>();
67
- untrack(() => remember(parsed.root, saved));
68
- root = parsed.root;
74
+ if (parsed.ok) {
75
+ root = parsed.root;
76
+ remember(root, saved);
77
+ loadIssues = [];
78
+ if (input.length) logicalOperator = root.operator;
79
+ } else {
80
+ root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
81
+ loadIssues = parsed.issues;
82
+ }
69
83
  preserved = saved;
70
- loadIssues = [];
71
- if (filters.length) logicalOperator = parsed.root.operator;
84
+ attempted = false;
72
85
  } else {
73
- preserved = new Map();
74
- loadIssues = parsed.issues;
75
- root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
86
+ // 元数据加载后只解锁已支持的历史条件;不能把失效草稿改成只读而绕过校验。
87
+ function unlock(node: FilterNode): void {
88
+ if (node.kind === 'group') node.children.forEach(unlock);
89
+ else if (node.readonly && compileFilterTree({ kind: 'group', id: 'probe', operator: 'and', children: [node] }, metadata).ok) node.readonly = false;
90
+ }
91
+ unlock(root);
76
92
  }
77
- attempted = false;
78
- } else {
79
- untrack(() => { void metadata; refreshReadonly(root); });
80
- }
93
+ });
81
94
  });
82
- function depthOf(target: FilterGroupNode, node: FilterGroupNode = root, depth = root.wrapped || logicalOperator === 'or' ? 1 : 0): number {
83
- if (node === target) return depth;
84
- for (const child of node.children) if (child.kind === 'group') {
85
- const found = depthOf(target, child, depth + 1);
86
- if (found >= 0) return found;
87
- }
88
- return -1;
89
- }
90
95
 
91
96
  function message(code: FilterIssueCode): string {
92
97
  const labels: Record<FilterIssueCode, [string, string]> = {
93
98
  'invalid-filter': ['条件结构不受支持,原条件未被修改', 'Unsupported filter structure; the original query is unchanged'],
94
99
  limit: ['条件数量或深度超过限制', 'Filter count or depth exceeds the limit'],
95
- 'unknown-field': ['字段不可用于筛选', 'Field is unavailable for filtering'], operator: ['操作符不适用于此字段', 'Operator is not supported for this field'],
96
- value: ['请输入有效的类型化值;集合与范围使用 JSON 数组', 'Enter a valid typed value; sets and ranges use JSON arrays'],
100
+ 'unknown-field': ['字段不可用于筛选', 'Field is unavailable for filtering'],
101
+ operator: ['操作符不适用于此字段', 'Operator is not supported for this field'],
102
+ value: ['请输入有效的类型化值', 'Enter a valid typed value'],
97
103
  'empty-group': ['条件组不能为空', 'A filter group cannot be empty'],
98
104
  };
99
105
  return labels[code][chinese ? 0 : 1];
100
106
  }
101
107
  function appendRule(group: FilterGroupNode): void {
102
- if (disabled || loadIssues.length || full || depthOf(group) < 0 || depthOf(group) >= FILTER_EDITOR_LIMITS.depth) return;
103
- const first = availableFields[0];
104
- if (!first) return;
105
- group.children = [...group.children, { kind: 'rule', id: `draft-${++nextId}`, field: first.key,
106
- operator: filterOperators(first).includes('contains') ? 'contains' : 'eq', value: undefined }];
108
+ if (disabled || loadIssues.length || full) return;
109
+ const field = availableFields[0];
110
+ if (!field) return;
111
+ group.children = [...group.children, { kind: 'rule', id: `draft-${++nextId}`, field: field.key,
112
+ operator: filterOperators(editorFields.find(item => item.key === field.key)).includes('contains') ? 'contains' : 'eq', value: undefined }];
107
113
  }
114
+
108
115
  function appendGroup(group: FilterGroupNode): void {
109
- if (disabled || loadIssues.length || full || depthOf(group) < 0 || depthOf(group) >= FILTER_EDITOR_LIMITS.depth) return;
116
+ if (disabled || loadIssues.length || full) return;
110
117
  group.children = [...group.children, { kind: 'group', id: `draft-${++nextId}`, operator: 'and', children: [] }];
111
118
  }
119
+
112
120
  function removeNode(group: FilterGroupNode, id: string): void {
113
121
  if (disabled || loadIssues.length) return;
114
- group.children = group.children.filter((node) => node.id !== id);
115
- }
116
- function changeField(rule: FilterRuleNode, key: string): void {
117
- if (disabled || rule.readonly) return;
118
- rule.field = key;
119
- rule.operator = filterOperators(fields.find((field) => field.key === key)).includes('contains') ? 'contains' : 'eq';
120
- rule.value = undefined;
121
- }
122
- function changeOperator(rule: FilterRuleNode, next: CrudOperator): void {
123
- if (disabled || rule.readonly) return;
124
- if (isNullOperator(next)) rule.value = null;
125
- else if (isNullOperator(rule.operator) || isCollectionOperator(rule.operator) !== isCollectionOperator(next)) rule.value = undefined;
126
- rule.operator = next;
127
- }
128
- function changeValue(rule: FilterRuleNode, raw: string): void {
129
- if (disabled || rule.readonly) return;
130
- try { rule.value = parseFilterInput(raw, fields.find((field) => field.key === rule.field), rule.operator); }
131
- catch { rule.value = raw; }
122
+ group.children = group.children.filter(child => child.id !== id);
132
123
  }
133
- function valueText(value: unknown): string { return Array.isArray(value) ? JSON.stringify(value) : value === undefined || value === null ? '' : String(value); }
124
+
134
125
  export function addRule(): void { appendRule(root); }
135
126
  export function removeRule(index: number): void { const node = root.children[index]; if (node) removeNode(root, node.id); }
136
127
  export function reset(): void {
@@ -138,21 +129,48 @@
138
129
  root = { kind: 'group', id: 'root', operator: 'and', children: [], wrapped: false };
139
130
  loadIssues = [];
140
131
  preserved = new Map();
141
- logicalOperator = 'and';
142
132
  filters = [];
133
+ lastInputFilters = filters;
143
134
  attempted = false;
144
135
  onReset?.();
145
136
  }
137
+
146
138
  export function apply(): void {
147
139
  if (disabled) return;
148
140
  attempted = true;
149
141
  if (loadIssues.length) { onInvalid?.(loadIssues); return; }
150
142
  if (!compilation.ok) { onInvalid?.(compilation.issues); return; }
151
- // 失败时绝不输出“剩余的有效条件”,避免改变查询的逻辑含义。
143
+ // 失败时不输出部分条件,避免改变查询含义。
152
144
  filters = compilation.filters;
153
145
  lastInputFilters = filters;
154
146
  onApply?.(compilation.filters);
155
147
  }
148
+
149
+ function changeField(rule: FilterRuleNode, key: string): void {
150
+ if (disabled || rule.readonly) return;
151
+ rule.field = key;
152
+ rule.operator = filterOperators(editorFields.find(field => field.key === key)).includes('contains') ? 'contains' : 'eq';
153
+ rule.value = undefined;
154
+ }
155
+
156
+ function changeValue(rule: FilterRuleNode, raw: string): void {
157
+ if (disabled || rule.readonly) return;
158
+ try { rule.value = parseFilterInput(raw, editorFields.find(field => field.key === rule.field), rule.operator); }
159
+ catch { rule.value = raw; }
160
+ }
161
+ function valueText(value: unknown): string { return Array.isArray(value) ? JSON.stringify(value) : value === undefined || value === null ? '' : String(value); }
162
+
163
+ function changeOperator(rule: FilterRuleNode, operator: CrudOperator): void {
164
+ if (disabled || rule.readonly) return;
165
+ const previous = rule.operator;
166
+ rule.operator = operator;
167
+ if (isNullOperator(operator)) rule.value = null;
168
+ else if (operator === 'between' || operator === 'nbetween') {
169
+ if (previous !== 'between' && previous !== 'nbetween') rule.value = [null, null];
170
+ } else if (isCollectionOperator(operator)) {
171
+ if (previous !== 'in' && previous !== 'nin') rule.value = [null];
172
+ } else if (isCollectionOperator(previous) || isNullOperator(previous)) rule.value = undefined;
173
+ }
156
174
  </script>
157
175
 
158
176
  {#snippet groupEditor(group: FilterGroupNode, depth: number, top: boolean)}
@@ -185,7 +203,7 @@
185
203
  </div>
186
204
  {:else}
187
205
  {@const field = availableFields.find((item) => item.key === node.field)}
188
- {@const allowed = filterOperators(field)}
206
+ {@const allowed = filterOperators(editorFields.find(item => item.key === node.field))}
189
207
  {@const collection = isCollectionOperator(node.operator)}
190
208
  {@const invalid = issues.some((issue) => issue.path === node.id)}
191
209
  <div class="filter-rule" data-testid="filter-builder-rule" data-filter-rule={node.id}>
@@ -210,6 +228,10 @@
210
228
  <label for={`${uid}-${node.id}-value`}>{collection ? (chinese ? '值(JSON 数组)' : 'Value (JSON array)') : (chinese ? '值' : 'Value')}</label>
211
229
  {#if isNullOperator(node.operator)}
212
230
  <Input id={`${uid}-${node.id}-value`} value={chinese ? '无需填值' : 'No value required'} disabled />
231
+ {:else if collection && field}
232
+ <FilterCollectionInput {field} value={node.value}
233
+ range={node.operator === 'between' || node.operator === 'nbetween'}
234
+ onchange={(value) => { if (!disabled && !node.readonly) node.value = value; }} />
213
235
  {:else if !collection && field?.options?.length}
214
236
  <Select id={`${uid}-${node.id}-value`} aria-invalid={invalid}
215
237
  value={field.options.findIndex((option) => option.value === node.value) < 0 ? '' : String(field.options.findIndex((option) => option.value === node.value))}
@@ -229,12 +251,12 @@
229
251
  onchange={(event: Event) => { if (event.currentTarget instanceof HTMLSelectElement) changeValue(node, event.currentTarget.value); }}>
230
252
  <option value="">{chinese ? '请选择' : 'Choose'}</option><option value="true">true</option><option value="false">false</option>
231
253
  </Select>
232
- {:else if !collection && field?.type === 'number'}
254
+ {:else if !collection && isNumericField(field)}
233
255
  <Input id={`${uid}-${node.id}-value`} type="number" step="any" aria-invalid={invalid} value={typeof node.value === 'number' ? node.value : undefined}
234
- oninput={(event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
256
+ oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
235
257
  {:else}
236
258
  <Input id={`${uid}-${node.id}-value`} type="text" aria-invalid={invalid} value={valueText(node.value)} placeholder={collection ? '[1, 2]' : ''}
237
- oninput={(event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
259
+ oninput={(event: Event) => { if (event.currentTarget instanceof HTMLInputElement) changeValue(node, event.currentTarget.value); }} />
238
260
  {/if}
239
261
  </div>
240
262
  {/if}