@svadmin/ui 0.88.1 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/app.css +4 -0
  2. package/dist/app.theme.css +4 -0
  3. package/dist/component-registry.svelte.d.ts +2 -2
  4. package/dist/components/AboutSettings.svelte +23 -4
  5. package/dist/components/AdminApp.svelte +6 -4
  6. package/dist/components/AdminApp.svelte.d.ts +3 -3
  7. package/dist/components/ApiSettings.svelte +43 -27
  8. package/dist/components/ApprovalActionCard.svelte +8 -9
  9. package/dist/components/ApprovalCenter.svelte +24 -5
  10. package/dist/components/AuditLogViewer.svelte +2 -2
  11. package/dist/components/AutoComplete.svelte +610 -0
  12. package/dist/components/AutoComplete.svelte.d.ts +25 -0
  13. package/dist/components/AutoForm.svelte +265 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +141 -36
  16. package/dist/components/AutoTable.svelte.d.ts +8 -0
  17. package/dist/components/BatchActionBar.svelte +42 -14
  18. package/dist/components/BatchActionBar.svelte.d.ts +5 -0
  19. package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
  20. package/dist/components/CancelTaskButton.svelte +14 -1
  21. package/dist/components/Cascader.svelte +412 -53
  22. package/dist/components/Cascader.svelte.d.ts +7 -1
  23. package/dist/components/ColorPicker.svelte +1 -1
  24. package/dist/components/ColorPicker.svelte.d.ts +1 -1
  25. package/dist/components/ComboboxField.svelte +270 -34
  26. package/dist/components/ComboboxField.svelte.d.ts +4 -0
  27. package/dist/components/CommandPalette.svelte +4 -4
  28. package/dist/components/DashboardView.svelte +73 -7
  29. package/dist/components/DashboardView.svelte.d.ts +3 -1
  30. package/dist/components/DateRangeInput.svelte +8 -1
  31. package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
  32. package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
  33. package/dist/components/DetailWorkspaceSections.svelte +233 -0
  34. package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
  35. package/dist/components/DrawerForm.svelte +78 -9
  36. package/dist/components/DrawerForm.svelte.d.ts +3 -3
  37. package/dist/components/ErrorBoundary.svelte +1 -1
  38. package/dist/components/ErrorComponent.svelte +19 -6
  39. package/dist/components/ErrorComponent.svelte.d.ts +1 -0
  40. package/dist/components/ErrorSummary.svelte +9 -4
  41. package/dist/components/FieldRenderer.svelte +203 -90
  42. package/dist/components/FieldRenderer.svelte.d.ts +16 -1
  43. package/dist/components/FileUpload.svelte +116 -51
  44. package/dist/components/FileUpload.svelte.d.ts +7 -27
  45. package/dist/components/FileUploadField.svelte +170 -0
  46. package/dist/components/FileUploadField.svelte.d.ts +21 -0
  47. package/dist/components/FilterBuilder.svelte +13 -7
  48. package/dist/components/FilterCollectionInput.svelte +9 -7
  49. package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
  50. package/dist/components/ForgotPasswordPage.svelte +1 -1
  51. package/dist/components/Header.svelte +1 -1
  52. package/dist/components/ImportWizard.svelte +197 -28
  53. package/dist/components/ImportWizard.svelte.d.ts +5 -1
  54. package/dist/components/InferencerPanel.svelte +4 -2
  55. package/dist/components/InfiniteList.svelte +52 -9
  56. package/dist/components/JsonSchemaForm.svelte +237 -42
  57. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
  58. package/dist/components/KanbanBoard.svelte +43 -7
  59. package/dist/components/KanbanBoard.svelte.d.ts +2 -2
  60. package/dist/components/Layout.svelte +1 -1
  61. package/dist/components/LoginPage.svelte +1 -1
  62. package/dist/components/MetricStrip.svelte +8 -3
  63. package/dist/components/MetricStrip.svelte.d.ts +1 -0
  64. package/dist/components/ModalForm.svelte +85 -9
  65. package/dist/components/ModalForm.svelte.d.ts +3 -3
  66. package/dist/components/MoneyInput.svelte +5 -3
  67. package/dist/components/MoneyInput.svelte.d.ts +4 -2
  68. package/dist/components/MultiTabKeepAlive.svelte +502 -43
  69. package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
  70. package/dist/components/NotificationsSettings.svelte +10 -10
  71. package/dist/components/NumberInput.svelte +5 -3
  72. package/dist/components/NumberInput.svelte.d.ts +4 -2
  73. package/dist/components/NumericInput.svelte +277 -25
  74. package/dist/components/NumericInput.svelte.d.ts +3 -1
  75. package/dist/components/PercentInput.svelte +5 -3
  76. package/dist/components/PercentInput.svelte.d.ts +4 -2
  77. package/dist/components/PermissionMatrix.svelte +44 -8
  78. package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
  79. package/dist/components/ProfilePage.svelte +38 -13
  80. package/dist/components/RadioGroup.svelte +203 -4
  81. package/dist/components/RadioGroup.svelte.d.ts +1 -1
  82. package/dist/components/Rate.svelte +3 -3
  83. package/dist/components/Rate.svelte.d.ts +2 -2
  84. package/dist/components/RecordDetailDrawer.svelte +1 -1
  85. package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
  86. package/dist/components/RegisterPage.svelte +1 -1
  87. package/dist/components/RelationPicker.svelte +39 -11
  88. package/dist/components/ResourceAccessGuard.svelte +7 -2
  89. package/dist/components/RetryTaskButton.svelte +14 -1
  90. package/dist/components/RolesSettings.svelte +74 -28
  91. package/dist/components/SecuritySettings.svelte +66 -51
  92. package/dist/components/SegmentedControl.svelte +4 -2
  93. package/dist/components/SegmentedControl.svelte.d.ts +1 -1
  94. package/dist/components/Sidebar.svelte +1 -1
  95. package/dist/components/StepsForm.svelte +62 -10
  96. package/dist/components/StepsForm.svelte.d.ts +4 -2
  97. package/dist/components/SvadminLogo.svelte +1 -0
  98. package/dist/components/SvarDataGrid.svelte +42 -24
  99. package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
  100. package/dist/components/SvarResourceRead.svelte +2 -0
  101. package/dist/components/SvarResourceTableView.svelte +4 -2
  102. package/dist/components/TagsInput.svelte +3 -3
  103. package/dist/components/TagsInput.svelte.d.ts +2 -2
  104. package/dist/components/TaskDetails.svelte +22 -16
  105. package/dist/components/TaskDetails.svelte.d.ts +1 -1
  106. package/dist/components/TaskList.svelte +57 -15
  107. package/dist/components/TaskQueueDrawer.svelte +54 -34
  108. package/dist/components/Toast.svelte +13 -2
  109. package/dist/components/Transfer.svelte +67 -5
  110. package/dist/components/Transfer.svelte.d.ts +4 -0
  111. package/dist/components/TreeSelect.svelte +337 -77
  112. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  113. package/dist/components/TreeTable.svelte +6 -1
  114. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  115. package/dist/components/UpdatePasswordPage.svelte +1 -1
  116. package/dist/components/VersionDiffViewer.svelte +9 -7
  117. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  118. package/dist/components/account/MemberDirectory.svelte +14 -10
  119. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  120. package/dist/components/content/AuditTimeline.svelte +8 -6
  121. package/dist/components/content/DataState.svelte +1 -1
  122. package/dist/components/content/FileList.svelte +44 -13
  123. package/dist/components/content/FileList.svelte.d.ts +4 -2
  124. package/dist/components/content/FileListRow.svelte +87 -0
  125. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  126. package/dist/components/content/ImportDropzone.svelte +109 -12
  127. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  128. package/dist/components/content/IntegrationCard.svelte +3 -3
  129. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  130. package/dist/components/content/MediaThumbnail.svelte +6 -2
  131. package/dist/components/detail-workspace.d.ts +60 -0
  132. package/dist/components/detail-workspace.js +3 -0
  133. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  134. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  135. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  136. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  137. package/dist/components/enterprise/schema-form-references.js +316 -0
  138. package/dist/components/fields/ArrayField.svelte +178 -14
  139. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  140. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  141. package/dist/components/fields/array-field-metadata.js +36 -0
  142. package/dist/components/file-upload-reference.d.ts +11 -0
  143. package/dist/components/file-upload-reference.js +50 -0
  144. package/dist/components/file-upload-types.d.ts +32 -0
  145. package/dist/components/file-upload-types.js +1 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  147. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  148. package/dist/components/json-schema-form-state.d.ts +19 -0
  149. package/dist/components/list-deletion.svelte.js +6 -1
  150. package/dist/components/list-state.svelte.d.ts +12 -1
  151. package/dist/components/list-state.svelte.js +56 -16
  152. package/dist/components/numeric-format.d.ts +44 -0
  153. package/dist/components/numeric-format.js +198 -0
  154. package/dist/components/record-detail.svelte.js +2 -0
  155. package/dist/components/saved-list-views.d.ts +9 -2
  156. package/dist/components/saved-list-views.js +42 -11
  157. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  158. package/dist/components/saved-list-views.svelte.js +56 -7
  159. package/dist/components/task-action.svelte.d.ts +1 -0
  160. package/dist/components/task-action.svelte.js +55 -3
  161. package/dist/components/task-utils.d.ts +1 -0
  162. package/dist/components/task-utils.js +18 -0
  163. package/dist/components/ui/input/input.svelte +1 -1
  164. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  165. package/dist/components/ui/sheet/sheet.svelte +4 -1
  166. package/dist/components/version-history.d.ts +14 -0
  167. package/dist/components/version-history.js +32 -0
  168. package/dist/components/workspace-tab-state.d.ts +78 -0
  169. package/dist/components/workspace-tab-state.js +587 -0
  170. package/dist/index.d.ts +13 -1
  171. package/dist/index.js +4 -0
  172. package/package.json +5 -5
@@ -1,5 +1,6 @@
1
1
  export type { FieldDefinition } from '@svadmin/core';
2
2
  import type { FieldDefinition } from '@svadmin/core';
3
+ import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
3
4
  import type { Snippet } from 'svelte';
4
5
  type $$ComponentProps = {
5
6
  field: FieldDefinition;
@@ -7,8 +8,22 @@ type $$ComponentProps = {
7
8
  onchange: (val: unknown) => void;
8
9
  disabled?: boolean;
9
10
  invalid?: boolean;
10
- errorId?: string;
11
+ errorId?: string | undefined;
12
+ id?: string;
11
13
  children?: Snippet;
14
+ /** 嵌套数组的可序列化值由外层 JSON 提交;原始文件保留原生通道。 */
15
+ nativeSubmit?: boolean;
16
+ /** 开启异步上传后,字段值只保存可序列化引用,不再接收 File。 */
17
+ upload?: UploadHandler;
18
+ cancelUpload?: UploadCancelHandler;
19
+ onUploadStateChange?: (state: {
20
+ busy: boolean;
21
+ unresolved: boolean;
22
+ }) => void;
23
+ /** AutoForm 的 JSON 文件模式;独立 FieldRenderer 默认保留原生 multipart。 */
24
+ fileReferenceMode?: boolean;
25
+ /** 记录/上传作用域或 reset 变化时只重挂载文件字段。 */
26
+ fileScopeKey?: string | number | object;
12
27
  };
13
28
  declare const FieldRenderer: import("svelte").Component<$$ComponentProps, {}, "">;
14
29
  type FieldRenderer = ReturnType<typeof FieldRenderer>;
@@ -1,34 +1,19 @@
1
+ <script module lang="ts">
2
+ /* eslint-disable no-import-assign */
3
+ export type {
4
+ UploadItemStatus, UploadItem, UploadSession, UploadCancellation,
5
+ UploadHandler, UploadCancelHandler,
6
+ } from './file-upload-types.js';
7
+ </script>
8
+
1
9
  <script lang="ts">
2
10
  import { onDestroy, untrack } from 'svelte';
3
11
  import { X, Upload, RotateCw, Ban } from '@lucide/svelte';
4
12
  import { Button } from './ui/button/index.js';
5
13
  import { useTranslation } from '@svadmin/core/i18n';
6
-
7
- export type UploadItemStatus = 'queued' | 'uploading' | 'success' | 'error' | 'cancelled';
8
-
9
- export interface UploadItem {
10
- id: string;
11
- file: File;
12
- status: UploadItemStatus;
13
- progress: number;
14
- url?: string;
15
- error?: string;
16
- uploadId?: string;
17
- cleanupStatus?: 'pending' | 'success' | 'error';
18
- }
19
-
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
- }
14
+ import type {
15
+ UploadItem, UploadSession, UploadCancellation, UploadHandler, UploadCancelHandler,
16
+ } from './file-upload-types.js';
32
17
 
33
18
  interface Props {
34
19
  id?: string;
@@ -39,8 +24,11 @@ export interface UploadCancellation {
39
24
  maxSize?: number;
40
25
  disabled?: boolean;
41
26
  required?: boolean;
42
- upload?: (file: File, session: UploadSession) => Promise<{ url?: string; uploadId?: string } | undefined> | Promise<void>;
43
- cancelUpload?: (file: File, cancellation: UploadCancellation) => Promise<void>;
27
+ ariaLabel?: string;
28
+ invalid?: boolean;
29
+ describedby?: string;
30
+ upload?: UploadHandler;
31
+ cancelUpload?: UploadCancelHandler;
44
32
  onChange?: (items: UploadItem[]) => void;
45
33
  onReject?: (file: File, reason: string) => void;
46
34
  class?: string;
@@ -55,6 +43,9 @@ export interface UploadCancellation {
55
43
  maxSize,
56
44
  disabled = false,
57
45
  required = false,
46
+ ariaLabel,
47
+ invalid = false,
48
+ describedby,
58
49
  upload,
59
50
  cancelUpload,
60
51
  onChange,
@@ -79,18 +70,53 @@ export interface UploadCancellation {
79
70
  reason?: UploadCancellation['reason'];
80
71
  cleanupStarted?: boolean;
81
72
  settled?: boolean;
73
+ // 已成功交付给宿主的尝试不可再被回收,即使 notify 期间宿主同步卸载或抛错。
74
+ succeeded?: boolean;
82
75
  }
83
76
  const attempts = new Map<string, UploadAttempt>();
84
77
  const latestAttempts = new Map<string, UploadAttempt>();
78
+ // 失败但已登记 uploadId 的尝试仍可能持有服务端临时会话,需保留到移除/替换/
79
+ // 卸载/作用域替换或用户重试时,再用原尝试捕获的回调回收。
80
+ const pendingCleanup = new Map<string, UploadAttempt>();
85
81
  let scopeEpoch = 0;
86
82
  let observedUpload = untrack(() => upload);
87
83
  let destroyed = false;
84
+ let pendingNativeReset: { event: Event; form: HTMLFormElement } | undefined;
85
+
86
+ $effect(() => {
87
+ const form = input?.form;
88
+ if (upload || !form) return;
89
+ const reset = (event: Event) => {
90
+ flushNativeReset();
91
+ pendingNativeReset = { event, form };
92
+ // 原生事件可能在监听器之间执行微任务,下一任务才读取最终取消状态。
93
+ setTimeout(flushNativeReset, 0);
94
+ };
95
+ form.addEventListener('reset', reset);
96
+ return () => {
97
+ form.removeEventListener('reset', reset);
98
+ if (pendingNativeReset?.form === form) pendingNativeReset = undefined;
99
+ };
100
+ });
101
+
102
+ function flushNativeReset(): void {
103
+ const reset = pendingNativeReset;
104
+ if (!reset || reset.event.eventPhase !== Event.NONE) return;
105
+ pendingNativeReset = undefined;
106
+ if (destroyed || upload || reset.event.defaultPrevented || input?.form !== reset.form) return;
107
+ items = [];
108
+ rejected = [];
109
+ latestAttempts.clear();
110
+ emitChange();
111
+ }
88
112
 
89
113
  $effect.pre(() => {
90
114
  if (upload === observedUpload) return;
91
115
  observedUpload = upload;
92
116
  scopeEpoch += 1;
93
117
  untrack(() => {
118
+ // 已失败的尝试没有 controller,但仍持有临时会话,作用域替换时一并回收。
119
+ for (const id of [...pendingCleanup.keys()]) retire(id, 'scope-change');
94
120
  const activeIds = [...controllers.keys()];
95
121
  for (const id of activeIds) retire(id, 'scope-change');
96
122
  const cancelledIds = new Set(activeIds);
@@ -104,16 +130,20 @@ export interface UploadCancellation {
104
130
  });
105
131
 
106
132
  function retire(id: string, reason: UploadCancellation['reason'] = 'cancel'): void {
107
- const attempt = attempts.get(id);
133
+ const attempt = attempts.get(id) ?? pendingCleanup.get(id);
108
134
  const controller = controllers.get(id);
109
135
  controllers.delete(id);
110
136
  attempts.delete(id);
137
+ pendingCleanup.delete(id);
111
138
  if (attempt) attempt.reason = reason;
112
139
  controller?.abort();
113
140
  if (attempt) cleanupAttempt(id, attempt);
114
141
  }
115
142
 
116
143
  function cleanupAttempt(id: string, attempt: UploadAttempt): void {
144
+ // 成功附件没有临时会话需要回收;此守卫必须早于 reason 判断,
145
+ // 因为宿主可能在成功通知中同步卸载,retire 会给成功尝试补上 reason。
146
+ if (attempt.succeeded) return;
117
147
  if (!attempt.reason || attempt.cleanupStarted) return;
118
148
  if (attempt?.uploadId && attempt.cancelUpload) {
119
149
  attempt.cleanupStarted = true;
@@ -137,11 +167,12 @@ export interface UploadCancellation {
137
167
 
138
168
  onDestroy(() => {
139
169
  destroyed = true;
140
- for (const id of controllers.keys()) retire(id, 'unmount');
170
+ for (const id of [...new Set([...controllers.keys(), ...pendingCleanup.keys()])]) retire(id, 'unmount');
141
171
  latestAttempts.clear();
142
172
  });
143
173
 
144
174
  function emitChange(): void {
175
+ syncNativeInput();
145
176
  onChange?.(items.map(item => ({ ...item })));
146
177
  }
147
178
 
@@ -167,6 +198,13 @@ export interface UploadCancellation {
167
198
  return undefined;
168
199
  }
169
200
 
201
+ function rejectionLabel(reason: string): string {
202
+ if (reason === 'File type is not accepted.') return i18n.t('upload.typeRejected');
203
+ if (reason === 'File is too large.') return i18n.t('upload.tooLarge');
204
+ if (reason === 'Maximum file count exceeded.') return i18n.t('upload.tooMany');
205
+ return i18n.t('upload.failed');
206
+ }
207
+
170
208
  const normalizedMaxFiles = $derived(
171
209
  multiple ? (Number.isSafeInteger(maxFiles) && maxFiles > 0 ? maxFiles : 10) : 1,
172
210
  );
@@ -193,9 +231,22 @@ export interface UploadCancellation {
193
231
  emitChange();
194
232
  }
195
233
 
234
+ function syncNativeInput(): void {
235
+ if (destroyed || upload || !input) return;
236
+ // 原生表单提交与已接收队列保持同源,避免提交被拒绝或已移除的文件。
237
+ const transfer = new DataTransfer();
238
+ for (const item of items) transfer.items.add(item.file);
239
+ input.files = transfer.files;
240
+ }
241
+
196
242
  async function process(item: UploadItem): Promise<void> {
197
243
  if (destroyed || !upload || disabled) return;
198
244
  if (controllers.has(item.id) || !items.some(candidate => candidate.id === item.id)) return;
245
+ // 重试复用同一 item:先按替换语义回收旧失败尝试的临时会话,避免泄漏。
246
+ if (pendingCleanup.has(item.id)) {
247
+ latestAttempts.delete(item.id);
248
+ retire(item.id, 'replace');
249
+ }
199
250
  const controller = new AbortController();
200
251
  controllers.set(item.id, controller);
201
252
  const attempt: UploadAttempt = {
@@ -227,6 +278,7 @@ export interface UploadCancellation {
227
278
  uploadId: undefined,
228
279
  cleanupStatus: undefined,
229
280
  });
281
+ let failed = false;
230
282
  try {
231
283
  // 宿主回调可以同步卸载或替换组件;通知返回后必须重新确认本次请求仍有效。
232
284
  if (destroyed || !current() || controller.signal.aborted) return;
@@ -240,6 +292,9 @@ export interface UploadCancellation {
240
292
  });
241
293
  if (result?.uploadId !== undefined) register(result.uploadId);
242
294
  const uploadId = result?.uploadId ?? attempt.uploadId;
295
+ // 先锁定成功语义,再通知宿主;通知中的同步卸载/抛错不得把成功对象
296
+ // 降级为失败或登记为待回收临时会话。
297
+ attempt.succeeded = true;
243
298
  updateCurrent({
244
299
  status: 'success',
245
300
  progress: 100,
@@ -247,6 +302,8 @@ export interface UploadCancellation {
247
302
  ...(uploadId === undefined ? {} : { uploadId }),
248
303
  });
249
304
  } catch (error) {
305
+ if (attempt.succeeded) return;
306
+ failed = true;
250
307
  if (!current()) return;
251
308
  updateItem(item.id, {
252
309
  status: controller.signal.aborted ? 'cancelled' : 'error',
@@ -257,31 +314,30 @@ export interface UploadCancellation {
257
314
  if (current()) {
258
315
  controllers.delete(item.id);
259
316
  attempts.delete(item.id);
317
+ if (failed && !controller.signal.aborted && attempt.uploadId && attempt.cancelUpload
318
+ && !attempt.cleanupStarted && latestAttempts.get(item.id) === attempt) {
319
+ pendingCleanup.set(item.id, attempt);
320
+ }
260
321
  }
261
322
  }
262
323
  }
263
324
 
264
325
  function addFiles(selected: File[]): void {
326
+ flushNativeReset();
265
327
  if (destroyed || disabled) return;
266
- const available = multiple
267
- ? Math.max(0, normalizedMaxFiles - items.length)
268
- : 1;
328
+ syncNativeInput();
269
329
  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))) {
330
+ let accepted = 0;
331
+ for (const file of selected) {
278
332
  if (destroyed) return;
279
- const reason = validate(file);
333
+ const full = multiple ? items.length >= normalizedMaxFiles : accepted > 0;
334
+ const reason = validate(file) ?? (full ? 'Maximum file count exceeded.' : undefined);
280
335
  if (reason) {
281
336
  rejected = [...rejected, { name: file.name, reason }];
282
337
  onReject?.(file, reason);
283
338
  continue;
284
339
  }
340
+ accepted += 1;
285
341
  const item: UploadItem = { id: nextId(), file, status: 'queued', progress: 0 };
286
342
  if (!multiple) {
287
343
  for (const previous of items) {
@@ -308,7 +364,7 @@ export interface UploadCancellation {
308
364
  retire(id, 'remove');
309
365
  latestAttempts.delete(id);
310
366
  items = items.filter(item => item.id !== id);
311
- if (input) input.value = '';
367
+ if (input && upload) input.value = '';
312
368
  emitChange();
313
369
  }
314
370
 
@@ -324,6 +380,7 @@ export interface UploadCancellation {
324
380
  }
325
381
 
326
382
  function handleKeydown(event: KeyboardEvent): void {
383
+ if (disabled) return;
327
384
  if (event.key === 'Enter' || event.key === ' ') {
328
385
  event.preventDefault();
329
386
  input?.click();
@@ -341,6 +398,9 @@ export interface UploadCancellation {
341
398
  {multiple}
342
399
  disabled={disabled}
343
400
  required={upload ? false : required}
401
+ aria-label={ariaLabel}
402
+ aria-invalid={invalid || undefined}
403
+ aria-describedby={describedby}
344
404
  class="svadmin-file-upload-input"
345
405
  onchange={handleInput}
346
406
  />
@@ -349,7 +409,12 @@ export interface UploadCancellation {
349
409
  role="button"
350
410
  tabindex={disabled ? -1 : 0}
351
411
  aria-disabled={disabled}
352
- onclick={() => input?.click()}
412
+ aria-label={ariaLabel}
413
+ aria-describedby={describedby}
414
+ data-invalid={invalid || undefined}
415
+ onclick={() => {
416
+ if (!disabled) input?.click();
417
+ }}
353
418
  onkeydown={handleKeydown}
354
419
  ondragover={(event) => event.preventDefault()}
355
420
  ondrop={(event) => {
@@ -367,32 +432,32 @@ export interface UploadCancellation {
367
432
  {#if rejected.length}
368
433
  <ul aria-label={i18n.t('upload.rejectedFiles')}>
369
434
  {#each rejected as rejection (rejection)}
370
- <li>{rejection.name}: {rejection.reason}</li>
435
+ <li>{rejection.name}: {rejectionLabel(rejection.reason)}</li>
371
436
  {/each}
372
437
  </ul>
373
438
  {/if}
374
439
 
375
440
  {#if items.length}
376
- <ul class="svadmin-file-upload-list" aria-label="Selected files">
441
+ <ul class="svadmin-file-upload-list" aria-label={i18n.t('upload.selectedFiles')}>
377
442
  {#each items as item (item.id)}
378
443
  <li>
379
444
  <span class="svadmin-file-upload-name">{item.file.name}</span>
380
445
  <span class="svadmin-file-upload-status">
381
- {#if item.status === 'uploading'}{item.progress}%{:else}{item.status}{/if}
446
+ {#if item.status === 'uploading'}{item.progress}%{:else}{i18n.t(`upload.status.${item.status}`)}{/if}
382
447
  </span>
383
448
  {#if item.status === 'uploading'}
384
- <Button type="button" variant="ghost" size="icon-sm" aria-label="Cancel upload" onclick={() => cancel(item.id)}>
449
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.cancel')} title={i18n.t('upload.cancel')} aria-describedby={describedby} aria-invalid={invalid || undefined} onclick={() => cancel(item.id)}>
385
450
  <Ban aria-hidden="true" />
386
451
  </Button>
387
452
  {:else if item.status === 'error' || item.status === 'cancelled'}
388
- <Button type="button" variant="ghost" size="icon-sm" aria-label="Retry upload" {disabled} onclick={() => retry(item)}>
453
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.retry')} title={i18n.t('upload.retry')} aria-describedby={describedby} aria-invalid={invalid || undefined} {disabled} onclick={() => retry(item)}>
389
454
  <RotateCw aria-hidden="true" />
390
455
  </Button>
391
456
  {/if}
392
- <Button type="button" variant="ghost" size="icon-sm" aria-label="Remove file" {disabled} onclick={() => remove(item.id)}>
457
+ <Button type="button" variant="ghost" size="icon-sm" aria-label={i18n.t('upload.remove')} title={i18n.t('upload.remove')} aria-describedby={describedby} aria-invalid={invalid || undefined} {disabled} onclick={() => remove(item.id)}>
393
458
  <X aria-hidden="true" />
394
459
  </Button>
395
- {#if item.error}<span role="alert">{item.error}</span>{/if}
460
+ {#if item.status === 'error' || item.status === 'cancelled'}<span role="alert">{i18n.t(item.status === 'cancelled' ? 'upload.cancelled' : 'upload.failed')}</span>{/if}
396
461
  {#if item.cleanupStatus === 'pending'}<span role="status">{i18n.t('upload.cleanupPending')}</span>{/if}
397
462
  {#if item.cleanupStatus === 'error'}<span role="alert">{i18n.t('upload.cleanupFailed')}</span>{/if}
398
463
  </li>
@@ -1,25 +1,5 @@
1
- export type UploadItemStatus = 'queued' | 'uploading' | 'success' | 'error' | 'cancelled';
2
- export interface UploadItem {
3
- id: string;
4
- file: File;
5
- status: UploadItemStatus;
6
- progress: number;
7
- url?: string;
8
- error?: string;
9
- uploadId?: string;
10
- cleanupStatus?: 'pending' | 'success' | 'error';
11
- }
12
- export interface UploadSession {
13
- signal: AbortSignal;
14
- idempotencyKey: string;
15
- setUploadId: (uploadId: string) => void;
16
- onProgress: (progress: number) => void;
17
- }
18
- export interface UploadCancellation {
19
- uploadId: string;
20
- idempotencyKey: string;
21
- reason: 'cancel' | 'remove' | 'replace' | 'scope-change' | 'unmount';
22
- }
1
+ export type { UploadItemStatus, UploadItem, UploadSession, UploadCancellation, UploadHandler, UploadCancelHandler, } from './file-upload-types.js';
2
+ import type { UploadItem, UploadHandler, UploadCancelHandler } from './file-upload-types.js';
23
3
  interface Props {
24
4
  id?: string;
25
5
  name?: string;
@@ -29,11 +9,11 @@ interface Props {
29
9
  maxSize?: number;
30
10
  disabled?: boolean;
31
11
  required?: boolean;
32
- upload?: (file: File, session: UploadSession) => Promise<{
33
- url?: string;
34
- uploadId?: string;
35
- } | undefined> | Promise<void>;
36
- cancelUpload?: (file: File, cancellation: UploadCancellation) => Promise<void>;
12
+ ariaLabel?: string;
13
+ invalid?: boolean;
14
+ describedby?: string;
15
+ upload?: UploadHandler;
16
+ cancelUpload?: UploadCancelHandler;
37
17
  onChange?: (items: UploadItem[]) => void;
38
18
  onReject?: (file: File, reason: string) => void;
39
19
  class?: string;
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ import type { FieldDefinition } from '@svadmin/core';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import { definedOptions } from '@svadmin/core/options';
5
+ import { onDestroy } from 'svelte';
6
+ import { X } from '@lucide/svelte';
7
+ import FileUpload from './FileUpload.svelte';
8
+ import type {
9
+ UploadCancelHandler, UploadHandler, UploadItem,
10
+ } from './file-upload-types.js';
11
+ import { Button } from './ui/button/index.js';
12
+ import {
13
+ mergeUploadReferences, normalizeUploadReferences, uploadReferenceFor,
14
+ uploadReferenceKey, uploadReferenceLabel, type UploadReference,
15
+ } from './file-upload-reference.js';
16
+
17
+ interface UploadState {
18
+ busy: boolean;
19
+ unresolved: boolean;
20
+ }
21
+
22
+ interface Props {
23
+ id?: string;
24
+ field: FieldDefinition;
25
+ value: unknown;
26
+ onchange: (value: unknown) => void;
27
+ disabled?: boolean;
28
+ invalid?: boolean;
29
+ describedby?: string;
30
+ upload?: UploadHandler;
31
+ cancelUpload?: UploadCancelHandler;
32
+ onUploadStateChange?: (state: UploadState) => void;
33
+ }
34
+
35
+ let {
36
+ id, field, value, onchange, disabled = false, invalid = false,
37
+ describedby, upload, cancelUpload, onUploadStateChange,
38
+ }: Props = $props();
39
+
40
+ const i18n = useTranslation();
41
+ const UPLOADING = new Set<UploadItem['status']>(['queued', 'uploading']);
42
+ let baseline = $state<UploadReference[]>([]);
43
+ let uploaded = $state<UploadReference[]>([]);
44
+ let lastEmitted = '';
45
+ let busy = $state(false);
46
+ let unresolved = $state(false);
47
+ let missingReference = $state(false);
48
+
49
+ // 外部 value 更新基线;本组件自己的写入由队列结算,避免重复回显。
50
+ $effect(() => {
51
+ const next = normalizeUploadReferences(value);
52
+ const key = JSON.stringify(next.map(uploadReferenceKey));
53
+ if (key === lastEmitted) return;
54
+ baseline = next;
55
+ lastEmitted = key;
56
+ });
57
+
58
+ // 单值字段成功上传后替代旧基线;多值字段始终保留原引用。
59
+ const displayedBaseline = $derived(
60
+ field.multiple || uploaded.length === 0 ? baseline : [],
61
+ );
62
+ const merged = $derived.by(() => field.multiple
63
+ ? mergeUploadReferences(baseline, uploaded)
64
+ : uploaded.length > 0 ? [uploaded[uploaded.length - 1] as UploadReference] : baseline.slice(0, 1));
65
+
66
+ function report(): void {
67
+ onUploadStateChange?.({ busy, unresolved });
68
+ }
69
+
70
+ function emit(next: UploadReference[]): void {
71
+ const key = JSON.stringify(next.map(uploadReferenceKey));
72
+ if (key === lastEmitted) return;
73
+ lastEmitted = key;
74
+ onchange(field.multiple ? next : next[0] ?? null);
75
+ }
76
+
77
+ function handleItems(items: UploadItem[]): void {
78
+ const successful = items.flatMap(item => {
79
+ const reference = uploadReferenceFor(item);
80
+ return reference === undefined ? [] : [reference];
81
+ });
82
+ missingReference = items.some(item =>
83
+ item.status === 'success' && uploadReferenceFor(item) === undefined);
84
+ busy = items.some(item => UPLOADING.has(item.status));
85
+ unresolved = missingReference || items.some(item =>
86
+ item.status === 'error' || item.status === 'cancelled');
87
+ if (!field.multiple) {
88
+ // 成功替换退休旧引用;下一次上传未成功时保留最近一次成功引用。
89
+ if (successful.length > 0) baseline = [];
90
+ else if (uploaded.length > 0) baseline = items.length > 0 ? uploaded.slice(-1) : [];
91
+ }
92
+ uploaded = successful;
93
+ // 缺少有效标识的成功结果不写入表单;移除该项后会重新结算剩余成功项。
94
+ if (!missingReference) emit(merged);
95
+ report();
96
+ }
97
+
98
+ function removeBaseline(index: number): void {
99
+ if (disabled) return;
100
+ baseline = baseline.filter((_reference, current) => current !== index);
101
+ emit(merged);
102
+ }
103
+
104
+ onDestroy(() => {
105
+ if (!busy && !unresolved) return;
106
+ busy = false;
107
+ unresolved = false;
108
+ report();
109
+ });
110
+ </script>
111
+
112
+ {#if displayedBaseline.length > 0}
113
+ <ul class="svadmin-upload-reference-list" aria-label={field.label}>
114
+ {#each displayedBaseline as reference, index (uploadReferenceKey(reference) + index)}
115
+ {@const label = uploadReferenceLabel(reference)}
116
+ <li class="svadmin-upload-reference">
117
+ <span class="svadmin-upload-reference-text">{label}</span>
118
+ <Button
119
+ type="button"
120
+ variant="ghost"
121
+ size="icon-sm"
122
+ aria-label={`${i18n.t('common.remove')} ${label}`}
123
+ aria-describedby={describedby}
124
+ aria-invalid={invalid || undefined}
125
+ {disabled}
126
+ onclick={() => removeBaseline(index)}
127
+ >
128
+ <X aria-hidden="true" />
129
+ </Button>
130
+ </li>
131
+ {/each}
132
+ </ul>
133
+ {/if}
134
+
135
+ {#if upload}
136
+ <FileUpload
137
+ {...definedOptions({ id })}
138
+ name={field.key}
139
+ {...definedOptions({ multiple: field.multiple })}
140
+ {...definedOptions({ required: field.required && baseline.length === 0 && uploaded.length === 0 })}
141
+ {disabled}
142
+ ariaLabel={field.label}
143
+ {invalid}
144
+ {...definedOptions({ describedby })}
145
+ {...definedOptions({ accept: field.accept, maxSize: field.maxSize })}
146
+ {upload}
147
+ {...definedOptions({ cancelUpload })}
148
+ onChange={handleItems}
149
+ />
150
+ {:else}
151
+ <!-- AutoForm 的托管字段没有上传回调时不接受 File,避免 File 进入 JSON 快照。 -->
152
+ <FileUpload
153
+ {...definedOptions({ id, multiple: field.multiple })}
154
+ disabled
155
+ ariaLabel={field.label}
156
+ {invalid}
157
+ {...definedOptions({ describedby })}
158
+ />
159
+ <p role="alert">{i18n.t('common.operationFailed')}</p>
160
+ {/if}
161
+
162
+ {#if missingReference}
163
+ <p role="alert">{i18n.t('common.operationFailed')}</p>
164
+ {/if}
165
+
166
+ <style>
167
+ .svadmin-upload-reference-list { display: grid; gap: 4px; margin: 0 0 8px; padding: 0; list-style: none; }
168
+ .svadmin-upload-reference { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
169
+ .svadmin-upload-reference-text { overflow: hidden; flex: 1; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; }
170
+ </style>
@@ -0,0 +1,21 @@
1
+ import type { FieldDefinition } from '@svadmin/core';
2
+ import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
3
+ interface UploadState {
4
+ busy: boolean;
5
+ unresolved: boolean;
6
+ }
7
+ interface Props {
8
+ id?: string;
9
+ field: FieldDefinition;
10
+ value: unknown;
11
+ onchange: (value: unknown) => void;
12
+ disabled?: boolean;
13
+ invalid?: boolean;
14
+ describedby?: string;
15
+ upload?: UploadHandler;
16
+ cancelUpload?: UploadCancelHandler;
17
+ onUploadStateChange?: (state: UploadState) => void;
18
+ }
19
+ declare const FileUploadField: import("svelte").Component<Props, {}, "">;
20
+ type FileUploadField = ReturnType<typeof FileUploadField>;
21
+ export default FileUploadField;