@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
@@ -4,8 +4,10 @@
4
4
 
5
5
  import { definedOptions } from '@svadmin/core/options';
6
6
 
7
- import { captureAdminContext, captureAuthSession, useResourceContract, getContractFormFields, deriveValidator, useNavigation, useCan, useForm } from '@svadmin/core';
7
+ import { captureAdminContext, captureAuthSession, useResourceContract, getContractFormFields, deriveValidator, useNavigation, useCan, useForm, parseQueryKey } from '@svadmin/core';
8
+ import { useQueryClient } from '@tanstack/svelte-query';
8
9
  import { slide } from 'svelte/transition';
10
+ import { untrack } from 'svelte';
9
11
  import type { FieldDefinition } from '@svadmin/core';
10
12
  import { useTranslation } from '@svadmin/core/i18n';
11
13
 
@@ -15,10 +17,13 @@
15
17
  import { Badge } from './ui/badge/index.js';
16
18
  import { Save, ArrowLeft, Loader2, AlertCircle } from '@lucide/svelte';
17
19
  import FieldRenderer from './FieldRenderer.svelte';
20
+ import type { UploadCancelHandler, UploadHandler } from './file-upload-types.js';
18
21
  import * as Alert from './ui/alert/index.js';
19
22
  import { Skeleton } from './ui/skeleton/index.js';
20
23
  import ConfirmDialog from './ConfirmDialog.svelte';
21
24
  import ErrorSummary from './ErrorSummary.svelte';
25
+ import { withoutArrayFieldMetadata } from './fields/array-field-metadata.js';
26
+ import { schemaFormSnapshot } from './json-schema-form-state.js';
22
27
  import { cn } from '../utils.js';
23
28
  import type { Snippet } from 'svelte';
24
29
 
@@ -27,18 +32,30 @@
27
32
  interface Props {
28
33
  resourceName: string;
29
34
  rendering?: ResourceRendering | undefined;
30
- id?: string | number;
35
+ id?: string | number | undefined;
31
36
  mode?: 'create' | 'edit' | 'clone' | 'show';
32
37
  columns?: 1 | 2 | 3 | 4;
33
38
  showHeader?: boolean;
34
39
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
35
- formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
40
+ formActions?: Snippet<[{ isLoading: boolean; isDisabled: boolean; onSubmit: () => void }]>;
36
41
  headerContent?: Snippet;
37
42
  onSuccess?: () => void;
38
43
  redirect?: 'list' | false;
39
44
  onCancel?: () => void;
40
45
  onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
46
+ /**
47
+ * 可选:当当前表单作用域的真实未保存状态变化时通知宿主。
48
+ * 状态只由 `form.isTainted()` 与上传阻塞派生,不根据 DOM 事件或值比较猜测。
49
+ */
50
+ onDirtyChange?: (dirty: boolean) => void;
41
51
  showErrorSummary?: boolean;
52
+ /** 透传给 file 字段的异步上传;成功后字段值只保存 url/uploadId 引用。 */
53
+ upload?: UploadHandler;
54
+ cancelUpload?: UploadCancelHandler;
55
+ /** 宿主显式选择的草稿字段白名单;只包含当前可编辑表单内的字段。 */
56
+ draftFields?: readonly string[];
57
+ /** 仅在宿主显式订阅时输出可分离的字段草稿;不可恢复状态下输出 undefined。 */
58
+ onDraftChange?: (draft: Record<string, unknown> | undefined) => void;
42
59
  }
43
60
 
44
61
  let {
@@ -55,7 +72,12 @@
55
72
  redirect = 'list',
56
73
  onCancel,
57
74
  onNavigationGuardReady,
75
+ onDirtyChange,
58
76
  showErrorSummary = true,
77
+ upload,
78
+ cancelUpload,
79
+ draftFields,
80
+ onDraftChange,
59
81
  }: Props = $props();
60
82
  const navigation = useNavigation();
61
83
  const context = captureAdminContext();
@@ -77,6 +99,8 @@
77
99
  }));
78
100
  const permissionPending = $derived(mode === 'create' ? createPermission.isLoading
79
101
  : readPermission.isLoading || (mode === 'clone' && createPermission.isLoading));
102
+ const permissionError = $derived(mode === 'create' ? createPermission.isError
103
+ : readPermission.isError || (mode === 'clone' && createPermission.isError));
80
104
  const allowed = $derived(mode === 'create' ? resource.canCreate !== false && createPermission.allowed
81
105
  : mode === 'edit' ? resource.canEdit !== false && readPermission.allowed
82
106
  : mode === 'show' ? resource.canShow !== false && readPermission.allowed
@@ -135,6 +159,8 @@
135
159
  case 'select': return field.options?.[0]?.value ?? '';
136
160
  case 'relation': return field.multiple ? [] : null;
137
161
  case 'json': return {};
162
+ // 文件/引用的空值不是字符串;未上传时保持 undefined 不进入合同快照。
163
+ case 'file': return undefined;
138
164
  default: return '';
139
165
  }
140
166
  }
@@ -157,6 +183,8 @@
157
183
  get dataProviderName() { return binding.dataProviderName; },
158
184
  get redirect() { return redirect; },
159
185
  warnWhenUnsavedChanges: true,
186
+ // 失败已经显示在表单内,不再额外弹一条相同的 toast。
187
+ errorNotification: false,
160
188
  get validate() { return validator; },
161
189
  get onMutationSuccess() {
162
190
  const callback = onSuccess;
@@ -169,13 +197,21 @@
169
197
  ? [{ fieldKey: field.key, label: field.label || field.key, message }] : [];
170
198
  }));
171
199
 
172
- const renderedDraft = $derived.by(() => {
200
+ const authoritativeDraft = $derived.by(() => {
173
201
  try {
174
202
  return { ok: true, values: form.ready && activeRendering ? activeRendering.draft(mode, form.values) : form.values };
175
203
  } catch {
176
204
  return { ok: false, values: {} };
177
205
  }
178
206
  });
207
+ let acceptedDraft = $state.raw<{
208
+ snapshot: typeof authoritativeDraft;
209
+ values: Record<string, unknown>;
210
+ }>();
211
+ // 仅回显本次写入已确认的 UI 身份;新的 Core 快照使缓存立即失效。
212
+ const renderedDraft = $derived(acceptedDraft?.snapshot === authoritativeDraft
213
+ ? { ok: authoritativeDraft.ok, values: acceptedDraft.values }
214
+ : authoritativeDraft);
179
215
 
180
216
  // ─── Submission error (non-field, e.g. network error) ─────────────
181
217
  let submitError = $state<string | null>(null);
@@ -188,13 +224,135 @@
188
224
  });
189
225
  let mounted = true;
190
226
  const currentScope = (origin: typeof scope) => mounted && scope === origin && origin.session.isCurrent();
227
+
228
+ // ─── Host-owned draft contract ────────────────────────────────────
229
+ // 只复制纯 JSON 值;调用方永远拿不到 form.values 的内部引用。
230
+ // 复用既有有界纯 JSON 快照:不读 getter、不执行序列化钩子,并受深度/节点预算保护。
231
+ function collectDraftOutput(): Record<string, unknown> | undefined {
232
+ const fields = draftFields;
233
+ if (!fields || fields.length === 0) return undefined;
234
+ const allowlist = new Set(fields);
235
+ const draft: Record<string, unknown> = {};
236
+ for (const field of formFields) {
237
+ if (!allowlist.has(field.key)) continue;
238
+ if (!Object.prototype.hasOwnProperty.call(form.values, field.key)) continue;
239
+ const value = form.values[field.key];
240
+ if (value === undefined) continue;
241
+ try {
242
+ draft[field.key] = schemaFormSnapshot({ field: value })['field'];
243
+ } catch {
244
+ continue;
245
+ }
246
+ }
247
+ return draft;
248
+ }
249
+ function draftSignature(draft: Record<string, unknown> | undefined): string {
250
+ return draft === undefined ? 'undefined' : JSON.stringify(draft);
251
+ }
252
+
253
+ /**
254
+ * 宿主显式恢复入口。候选先整体做纯 JSON 快照,再检查白名单与真实可编辑字段;
255
+ * 全部通过后只调用一次 `form.setValues` 原子写入。业务必填/格式校验留到提交;
256
+ * 提交中、上传阻塞、只读、未授权、换作用域或已卸载时返回 false,异常不外抛。
257
+ */
258
+ export function restoreDraft(candidate: Record<string, unknown>): boolean {
259
+ try {
260
+ if (!mounted || !currentScope(scope) || !form.ready || !allowed || isReadonly
261
+ || form.submitting || uploadBlocked) return false;
262
+ const fields = draftFields;
263
+ if (!fields || fields.length === 0) return false;
264
+ let snapshot: Record<string, unknown>;
265
+ try {
266
+ snapshot = schemaFormSnapshot(candidate);
267
+ } catch {
268
+ return false;
269
+ }
270
+ const allowlist = new Set(fields);
271
+ const editable = new Set(formFields.map(field => field.key));
272
+ const values: Record<string, unknown> = {};
273
+ for (const [key, value] of Object.entries(snapshot)) {
274
+ if (!allowlist.has(key) || !editable.has(key)) return false;
275
+ values[key] = value;
276
+ }
277
+ if (Object.keys(values).length === 0) return false;
278
+ form.setValues(values, { taint: true });
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
284
+
285
+ let lastDraftScope: typeof scope | undefined;
286
+ let lastDraftCallback: Props['onDraftChange'];
287
+ let lastDraftSignature: string | undefined;
288
+ $effect(() => {
289
+ const origin = scope;
290
+ const fields = draftFields;
291
+ const callback = onDraftChange;
292
+ const ready = form.ready;
293
+ const permitted = allowed;
294
+ const readonly = isReadonly;
295
+ const submitting = form.submitting;
296
+ const renderingOk = renderedDraft.ok;
297
+ if (!fields || fields.length === 0 || !callback) return;
298
+ if (!currentScope(origin)) return;
299
+ const usable = ready && permitted && !readonly && !submitting && renderingOk;
300
+ const output = usable ? collectDraftOutput() : undefined;
301
+ const signature = draftSignature(output);
302
+ if (lastDraftScope !== origin || lastDraftCallback !== callback) {
303
+ lastDraftScope = origin;
304
+ lastDraftCallback = callback;
305
+ lastDraftSignature = undefined;
306
+ }
307
+ if (lastDraftSignature === signature) return;
308
+ lastDraftSignature = signature;
309
+ untrack(() => callback(output));
310
+ });
311
+
191
312
  let submission: object | undefined;
313
+ interface UploadState { busy: boolean; unresolved: boolean }
314
+ // 按字段记录上传队列:busy 阻止提交并驱动 loading,unresolved 阻止提交。
315
+ let uploadStates = $state<Record<string, UploadState>>({});
316
+ const uploadPending = $derived(Object.values(uploadStates).some(state => state.busy));
317
+ const uploadBlocked = $derived(Object.values(uploadStates).some(state => state.busy || state.unresolved));
318
+ let lastDirty: boolean | undefined;
319
+ let lastDirtyScope: typeof scope | undefined;
320
+ $effect(() => {
321
+ const origin = scope;
322
+ const dirty = form.isTainted() || uploadBlocked;
323
+ if (!currentScope(origin)) return;
324
+ if (lastDirtyScope !== origin) {
325
+ lastDirtyScope = origin;
326
+ lastDirty = undefined;
327
+ }
328
+ if (lastDirty === dirty) return;
329
+ lastDirty = dirty;
330
+ untrack(() => onDirtyChange?.(dirty));
331
+ });
332
+ // 记录/provider/auth 作用域变化时,只重挂载文件字段并丢弃旧回执。
333
+ let resetEpoch = $state(0);
334
+ const fileScopeKey = $derived({ scope, resetEpoch, upload, cancelUpload });
335
+ $effect(() => {
336
+ void fileScopeKey;
337
+ uploadStates = {};
338
+ });
192
339
  $effect(() => {
193
340
  void scope;
194
341
  submission = undefined;
195
342
  submitError = null;
196
343
  confirmOpen = false;
197
344
  pendingNavigation = null;
345
+ uploadStates = {};
346
+ });
347
+ const setUploadState = $derived.by(() => {
348
+ const origin = scope;
349
+ const originFileScope = fileScopeKey;
350
+ return (fieldKey: string, state: UploadState) => {
351
+ if (!currentScope(origin) || fileScopeKey !== originFileScope) return;
352
+ const previous = uploadStates[fieldKey];
353
+ if (previous?.busy === state.busy && previous?.unresolved === state.unresolved) return;
354
+ uploadStates = { ...uploadStates, [fieldKey]: state };
355
+ };
198
356
  });
199
357
  $effect(() => () => { mounted = false; submission = undefined; });
200
358
 
@@ -202,24 +360,64 @@
202
360
  return `${instanceId}-${resourceName}-${fieldKey}-error`;
203
361
  }
204
362
 
363
+ const focusableFieldSelector = [
364
+ 'input:not([type="hidden"])', 'textarea', 'select', 'button', '[tabindex]',
365
+ ].join(', ');
366
+ const richTextFieldSelector = '[contenteditable="true"][role="textbox"]';
367
+
368
+ function isActionableControl(element: HTMLElement): boolean {
369
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
370
+ if (!(parent instanceof HTMLFieldSetElement) || !parent.disabled) continue;
371
+ const legend = Array.from(parent.children).find(child => child instanceof HTMLLegendElement);
372
+ if (!legend?.contains(element)) return false;
373
+ }
374
+ return !element.hasAttribute('disabled')
375
+ && !element.matches('input[type="hidden"]')
376
+ && !element.matches(':disabled')
377
+ && !element.closest('[hidden], [inert], [aria-disabled="true"]');
378
+ }
379
+
380
+ function focusFirstActionableControl(field: Element, definition?: FieldDefinition): boolean {
381
+ // 仅默认渲染的顶层 richtext 字段优先正文;数组等容器仍按 DOM 顺序,
382
+ // 自定义 fieldRenderer 也不替宿主猜测正文入口。
383
+ if (!fieldRenderer && definition?.type === 'richtext') {
384
+ const richText = field.querySelector<HTMLElement>(richTextFieldSelector);
385
+ if (richText && isActionableControl(richText)) {
386
+ richText.focus();
387
+ if (document.activeElement === richText) return true;
388
+ }
389
+ }
390
+ const candidates = field.querySelectorAll<HTMLElement>(focusableFieldSelector);
391
+ for (const candidate of candidates) {
392
+ if (!isActionableControl(candidate)) continue;
393
+ candidate.focus();
394
+ if (document.activeElement === candidate) return true;
395
+ }
396
+ return false;
397
+ }
398
+
205
399
  function focusFirstInvalidField(): void {
206
- const firstInvalid = formElement?.querySelector<HTMLElement>('[aria-invalid="true"]');
207
- firstInvalid?.focus();
400
+ if (!formElement) return;
401
+ for (const field of formElement.querySelectorAll<HTMLElement>('[data-svadmin-form-field]')) {
402
+ const key = field.getAttribute('data-svadmin-field-key');
403
+ const error = key ? form.errors[key] : undefined;
404
+ const definition = key ? formFields.find(fieldDefinition => fieldDefinition.key === key) : undefined;
405
+ if (typeof error === 'string' && error.length > 0 && focusFirstActionableControl(field, definition)) return;
406
+ }
208
407
  }
209
408
 
210
409
  function focusField(fieldKey: string): void {
211
410
  const field = formElement?.querySelector<HTMLElement>(
212
- `[data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
411
+ `[data-svadmin-form-field][data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
213
412
  );
214
- field?.querySelector<HTMLElement>(
215
- 'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])',
216
- )?.focus();
413
+ if (field) focusFirstActionableControl(field, formFields.find(definition => definition.key === fieldKey));
217
414
  }
218
415
 
219
416
  const handleSubmit = $derived.by(() => {
220
417
  const current = scope;
221
418
  return async () => {
222
- if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting || submission) return;
419
+ if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting
420
+ || uploadBlocked || submission) return;
223
421
  const token = {};
224
422
  submission = token;
225
423
  submitError = null;
@@ -244,7 +442,15 @@
244
442
  const fieldChange = $derived.by(() => {
245
443
  const origin = scope;
246
444
  return (key: string) => (value: unknown) => {
247
- if (currentScope(origin)) form.setFieldValue(key, value);
445
+ if (!currentScope(origin) || !form.ready || !allowed || isReadonly) return;
446
+ const previous = renderedDraft.values;
447
+ form.setFieldValue(key, withoutArrayFieldMetadata(value));
448
+ if (currentScope(origin) && authoritativeDraft.ok) {
449
+ acceptedDraft = {
450
+ snapshot: authoritativeDraft,
451
+ values: { ...previous, [key]: value },
452
+ };
453
+ }
248
454
  };
249
455
  });
250
456
  const retry = $derived.by(() => {
@@ -252,6 +458,32 @@
252
458
  const refetch = form.query.refetch;
253
459
  return () => { if (currentScope(origin)) void refetch().catch(() => {}); };
254
460
  });
461
+ const queryClient = useQueryClient();
462
+ function refreshPermission() {
463
+ const resource = resourceName;
464
+ void queryClient.invalidateQueries({
465
+ predicate: query => {
466
+ const parsed = parseQueryKey(query.queryKey);
467
+ return parsed?.kind === 'access' && parsed.resource === resource;
468
+ },
469
+ }).catch(() => {});
470
+ }
471
+
472
+ // 显式 reset 入口:宿主通过 bind:this 调用,恢复 Core 初始草稿并清理本实例
473
+ // 的提交令牌/提示;不触发导航,也不改变只读或未授权状态。
474
+ export function reset(): void {
475
+ if (!allowed || isReadonly) return;
476
+ submission = undefined;
477
+ submitError = null;
478
+ // reset 后基线不再 tainted,旧的关闭保护与其待执行导航必须一起退休,
479
+ // 否则后续确认会执行 reset 前捕获的导航。
480
+ confirmOpen = false;
481
+ pendingNavigation = null;
482
+ uploadStates = {};
483
+ // reset token 重挂载文件字段:旧 FileUpload 会按 unmount 回收在途上传。
484
+ resetEpoch += 1;
485
+ form.reset();
486
+ }
255
487
 
256
488
  const pageTitle = $derived(
257
489
  mode === 'create'
@@ -272,7 +504,7 @@
272
504
  return (fn: () => void) => {
273
505
  if (!currentScope(origin)) return;
274
506
  if (form.submitting) return;
275
- if (form.isTainted()) {
507
+ if (form.isTainted() || uploadBlocked) {
276
508
  pendingNavigation = () => { if (currentScope(origin)) fn(); };
277
509
  confirmOpen = true;
278
510
  } else {
@@ -343,7 +575,14 @@
343
575
  </div>
344
576
  </div>
345
577
  {:else if !allowed}
346
- <p role="alert">{i18n.t('common.operationFailed')}</p>
578
+ {#if permissionError}
579
+ <p role="alert">{i18n.t('common.permissionUnavailable')}</p>
580
+ <Button type="button" variant="outline" onclick={refreshPermission}>
581
+ {i18n.t('common.retry')}
582
+ </Button>
583
+ {:else}
584
+ <p role="alert">{i18n.t('common.permissionDenied')}</p>
585
+ {/if}
347
586
  {:else if !form.ready && form.error}
348
587
  <p role="alert">{i18n.t('common.operationFailed')}</p>
349
588
  <Button type="button" variant="outline" onclick={retry}>
@@ -382,7 +621,7 @@
382
621
  <Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-7a9aabfcd059 svadmin-u-9fcd8a13827e svadmin-u-050494726fba svadmin-u-9335c39f6eff">
383
622
  <div class={gridClass}>
384
623
  {#each group.fields as field (field.key)}
385
- <div data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
624
+ <div data-svadmin-form-field data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
386
625
  {#if fieldRenderer}
387
626
  {@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
388
627
  {:else}
@@ -393,6 +632,10 @@
393
632
  invalid={!!form.errors[field.key]}
394
633
  {...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
395
634
  disabled={isReadonly}
635
+ fileReferenceMode
636
+ {fileScopeKey}
637
+ {...definedOptions({ upload, cancelUpload })}
638
+ onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
396
639
  />
397
640
  {/if}
398
641
  {#if form.errors[field.key]}
@@ -409,7 +652,7 @@
409
652
  <Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-52be28846b5f svadmin-u-9fcd8a13827e svadmin-u-7a9aabfcd059 svadmin-u-0a58453f3755 svadmin-u-050494726fba">
410
653
  <div class={gridClass} data-svadmin-form-grid data-columns={columns}>
411
654
  {#each formFields as field (field.key)}
412
- <div data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
655
+ <div data-svadmin-form-field data-svadmin-field-key={field.key} class={cn(columns > 1 && isFullWidthField(field) && 'svadmin-u-2c955d1b45df', !!form.errors[field.key] && 'svadmin-u-ee1a5af3aa10')}>
413
656
  {#if fieldRenderer}
414
657
  {@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
415
658
  {:else}
@@ -420,6 +663,10 @@
420
663
  invalid={!!form.errors[field.key]}
421
664
  {...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
422
665
  disabled={isReadonly}
666
+ fileReferenceMode
667
+ {fileScopeKey}
668
+ {...definedOptions({ upload, cancelUpload })}
669
+ onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
423
670
  />
424
671
  {/if}
425
672
  {#if form.errors[field.key]}
@@ -434,9 +681,9 @@
434
681
 
435
682
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-1004c0c3954c">
436
683
  {#if formActions && !isReadonly}
437
- {@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
684
+ {@render formActions({ isLoading: form.submitting || uploadPending, isDisabled: form.submitting || uploadBlocked, onSubmit: handleSubmit })}
438
685
  {:else if !isReadonly}
439
- <Button type="submit" size="default" disabled={form.submitting}>
686
+ <Button type="submit" size="default" disabled={form.submitting || uploadBlocked}>
440
687
  {#if form.submitting}
441
688
  <Loader2 class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-afbdd13a380e" data-icon="inline-start" aria-hidden="true" />
442
689
  {:else}
@@ -1,10 +1,11 @@
1
1
  import { type ResourceRendering } from '../rendering/index.js';
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
  interface Props {
5
6
  resourceName: string;
6
7
  rendering?: ResourceRendering | undefined;
7
- id?: string | number;
8
+ id?: string | number | undefined;
8
9
  mode?: 'create' | 'edit' | 'clone' | 'show';
9
10
  columns?: 1 | 2 | 3 | 4;
10
11
  showHeader?: boolean;
@@ -15,6 +16,7 @@ interface Props {
15
16
  }]>;
16
17
  formActions?: Snippet<[{
17
18
  isLoading: boolean;
19
+ isDisabled: boolean;
18
20
  onSubmit: () => void;
19
21
  }]>;
20
22
  headerContent?: Snippet;
@@ -22,8 +24,23 @@ interface Props {
22
24
  redirect?: 'list' | false;
23
25
  onCancel?: () => void;
24
26
  onNavigationGuardReady?: (guard: (fn: () => void) => void) => void;
27
+ /**
28
+ * 可选:当当前表单作用域的真实未保存状态变化时通知宿主。
29
+ * 状态只由 `form.isTainted()` 与上传阻塞派生,不根据 DOM 事件或值比较猜测。
30
+ */
31
+ onDirtyChange?: (dirty: boolean) => void;
25
32
  showErrorSummary?: boolean;
33
+ /** 透传给 file 字段的异步上传;成功后字段值只保存 url/uploadId 引用。 */
34
+ upload?: UploadHandler;
35
+ cancelUpload?: UploadCancelHandler;
36
+ /** 宿主显式选择的草稿字段白名单;只包含当前可编辑表单内的字段。 */
37
+ draftFields?: readonly string[];
38
+ /** 仅在宿主显式订阅时输出可分离的字段草稿;不可恢复状态下输出 undefined。 */
39
+ onDraftChange?: (draft: Record<string, unknown> | undefined) => void;
26
40
  }
27
- declare const AutoForm: import("svelte").Component<Props, {}, "">;
41
+ declare const AutoForm: import("svelte").Component<Props, {
42
+ restoreDraft: (candidate: Record<string, unknown>) => boolean;
43
+ reset: () => void;
44
+ }, "">;
28
45
  type AutoForm = ReturnType<typeof AutoForm>;
29
46
  export default AutoForm;