@svadmin/ui 0.88.1 → 0.91.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 (171) 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 +263 -18
  14. package/dist/components/AutoForm.svelte.d.ts +19 -2
  15. package/dist/components/AutoTable.svelte +135 -35
  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 +60 -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/Transfer.svelte +67 -5
  109. package/dist/components/Transfer.svelte.d.ts +4 -0
  110. package/dist/components/TreeSelect.svelte +337 -77
  111. package/dist/components/TreeSelect.svelte.d.ts +4 -0
  112. package/dist/components/TreeTable.svelte +6 -1
  113. package/dist/components/TwoFactorAuthPage.svelte +8 -4
  114. package/dist/components/UpdatePasswordPage.svelte +1 -1
  115. package/dist/components/VersionDiffViewer.svelte +9 -7
  116. package/dist/components/account/CompanyProfilePage.svelte +13 -12
  117. package/dist/components/account/MemberDirectory.svelte +14 -10
  118. package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
  119. package/dist/components/content/AuditTimeline.svelte +8 -6
  120. package/dist/components/content/DataState.svelte +1 -1
  121. package/dist/components/content/FileList.svelte +44 -13
  122. package/dist/components/content/FileList.svelte.d.ts +4 -2
  123. package/dist/components/content/FileListRow.svelte +87 -0
  124. package/dist/components/content/FileListRow.svelte.d.ts +11 -0
  125. package/dist/components/content/ImportDropzone.svelte +109 -12
  126. package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
  127. package/dist/components/content/IntegrationCard.svelte +3 -3
  128. package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
  129. package/dist/components/content/MediaThumbnail.svelte +6 -2
  130. package/dist/components/detail-workspace.d.ts +60 -0
  131. package/dist/components/detail-workspace.js +3 -0
  132. package/dist/components/enterprise/json-schema-form.d.ts +1 -1
  133. package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
  134. package/dist/components/enterprise/recursive-schema-form.js +288 -14
  135. package/dist/components/enterprise/schema-form-references.d.ts +14 -0
  136. package/dist/components/enterprise/schema-form-references.js +316 -0
  137. package/dist/components/fields/ArrayField.svelte +178 -14
  138. package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
  139. package/dist/components/fields/array-field-metadata.d.ts +4 -0
  140. package/dist/components/fields/array-field-metadata.js +36 -0
  141. package/dist/components/file-upload-reference.d.ts +11 -0
  142. package/dist/components/file-upload-reference.js +50 -0
  143. package/dist/components/file-upload-types.d.ts +32 -0
  144. package/dist/components/file-upload-types.js +1 -0
  145. package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
  146. package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
  147. package/dist/components/json-schema-form-state.d.ts +19 -0
  148. package/dist/components/list-deletion.svelte.js +6 -1
  149. package/dist/components/list-state.svelte.d.ts +12 -1
  150. package/dist/components/list-state.svelte.js +56 -16
  151. package/dist/components/numeric-format.d.ts +44 -0
  152. package/dist/components/numeric-format.js +198 -0
  153. package/dist/components/record-detail.svelte.js +2 -0
  154. package/dist/components/saved-list-views.d.ts +9 -2
  155. package/dist/components/saved-list-views.js +42 -11
  156. package/dist/components/saved-list-views.svelte.d.ts +3 -0
  157. package/dist/components/saved-list-views.svelte.js +56 -7
  158. package/dist/components/task-action.svelte.d.ts +1 -0
  159. package/dist/components/task-action.svelte.js +55 -3
  160. package/dist/components/task-utils.d.ts +1 -0
  161. package/dist/components/task-utils.js +18 -0
  162. package/dist/components/ui/input/input.svelte +1 -1
  163. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  164. package/dist/components/ui/sheet/sheet.svelte +4 -1
  165. package/dist/components/version-history.d.ts +14 -0
  166. package/dist/components/version-history.js +32 -0
  167. package/dist/components/workspace-tab-state.d.ts +78 -0
  168. package/dist/components/workspace-tab-state.js +587 -0
  169. package/dist/index.d.ts +13 -1
  170. package/dist/index.js +4 -0
  171. 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
  }
@@ -169,13 +195,21 @@
169
195
  ? [{ fieldKey: field.key, label: field.label || field.key, message }] : [];
170
196
  }));
171
197
 
172
- const renderedDraft = $derived.by(() => {
198
+ const authoritativeDraft = $derived.by(() => {
173
199
  try {
174
200
  return { ok: true, values: form.ready && activeRendering ? activeRendering.draft(mode, form.values) : form.values };
175
201
  } catch {
176
202
  return { ok: false, values: {} };
177
203
  }
178
204
  });
205
+ let acceptedDraft = $state.raw<{
206
+ snapshot: typeof authoritativeDraft;
207
+ values: Record<string, unknown>;
208
+ }>();
209
+ // 仅回显本次写入已确认的 UI 身份;新的 Core 快照使缓存立即失效。
210
+ const renderedDraft = $derived(acceptedDraft?.snapshot === authoritativeDraft
211
+ ? { ok: authoritativeDraft.ok, values: acceptedDraft.values }
212
+ : authoritativeDraft);
179
213
 
180
214
  // ─── Submission error (non-field, e.g. network error) ─────────────
181
215
  let submitError = $state<string | null>(null);
@@ -188,13 +222,135 @@
188
222
  });
189
223
  let mounted = true;
190
224
  const currentScope = (origin: typeof scope) => mounted && scope === origin && origin.session.isCurrent();
225
+
226
+ // ─── Host-owned draft contract ────────────────────────────────────
227
+ // 只复制纯 JSON 值;调用方永远拿不到 form.values 的内部引用。
228
+ // 复用既有有界纯 JSON 快照:不读 getter、不执行序列化钩子,并受深度/节点预算保护。
229
+ function collectDraftOutput(): Record<string, unknown> | undefined {
230
+ const fields = draftFields;
231
+ if (!fields || fields.length === 0) return undefined;
232
+ const allowlist = new Set(fields);
233
+ const draft: Record<string, unknown> = {};
234
+ for (const field of formFields) {
235
+ if (!allowlist.has(field.key)) continue;
236
+ if (!Object.prototype.hasOwnProperty.call(form.values, field.key)) continue;
237
+ const value = form.values[field.key];
238
+ if (value === undefined) continue;
239
+ try {
240
+ draft[field.key] = schemaFormSnapshot({ field: value })['field'];
241
+ } catch {
242
+ continue;
243
+ }
244
+ }
245
+ return draft;
246
+ }
247
+ function draftSignature(draft: Record<string, unknown> | undefined): string {
248
+ return draft === undefined ? 'undefined' : JSON.stringify(draft);
249
+ }
250
+
251
+ /**
252
+ * 宿主显式恢复入口。候选先整体做纯 JSON 快照,再检查白名单与真实可编辑字段;
253
+ * 全部通过后只调用一次 `form.setValues` 原子写入。业务必填/格式校验留到提交;
254
+ * 提交中、上传阻塞、只读、未授权、换作用域或已卸载时返回 false,异常不外抛。
255
+ */
256
+ export function restoreDraft(candidate: Record<string, unknown>): boolean {
257
+ try {
258
+ if (!mounted || !currentScope(scope) || !form.ready || !allowed || isReadonly
259
+ || form.submitting || uploadBlocked) return false;
260
+ const fields = draftFields;
261
+ if (!fields || fields.length === 0) return false;
262
+ let snapshot: Record<string, unknown>;
263
+ try {
264
+ snapshot = schemaFormSnapshot(candidate);
265
+ } catch {
266
+ return false;
267
+ }
268
+ const allowlist = new Set(fields);
269
+ const editable = new Set(formFields.map(field => field.key));
270
+ const values: Record<string, unknown> = {};
271
+ for (const [key, value] of Object.entries(snapshot)) {
272
+ if (!allowlist.has(key) || !editable.has(key)) return false;
273
+ values[key] = value;
274
+ }
275
+ if (Object.keys(values).length === 0) return false;
276
+ form.setValues(values, { taint: true });
277
+ return true;
278
+ } catch {
279
+ return false;
280
+ }
281
+ }
282
+
283
+ let lastDraftScope: typeof scope | undefined;
284
+ let lastDraftCallback: Props['onDraftChange'];
285
+ let lastDraftSignature: string | undefined;
286
+ $effect(() => {
287
+ const origin = scope;
288
+ const fields = draftFields;
289
+ const callback = onDraftChange;
290
+ const ready = form.ready;
291
+ const permitted = allowed;
292
+ const readonly = isReadonly;
293
+ const submitting = form.submitting;
294
+ const renderingOk = renderedDraft.ok;
295
+ if (!fields || fields.length === 0 || !callback) return;
296
+ if (!currentScope(origin)) return;
297
+ const usable = ready && permitted && !readonly && !submitting && renderingOk;
298
+ const output = usable ? collectDraftOutput() : undefined;
299
+ const signature = draftSignature(output);
300
+ if (lastDraftScope !== origin || lastDraftCallback !== callback) {
301
+ lastDraftScope = origin;
302
+ lastDraftCallback = callback;
303
+ lastDraftSignature = undefined;
304
+ }
305
+ if (lastDraftSignature === signature) return;
306
+ lastDraftSignature = signature;
307
+ untrack(() => callback(output));
308
+ });
309
+
191
310
  let submission: object | undefined;
311
+ interface UploadState { busy: boolean; unresolved: boolean }
312
+ // 按字段记录上传队列:busy 阻止提交并驱动 loading,unresolved 阻止提交。
313
+ let uploadStates = $state<Record<string, UploadState>>({});
314
+ const uploadPending = $derived(Object.values(uploadStates).some(state => state.busy));
315
+ const uploadBlocked = $derived(Object.values(uploadStates).some(state => state.busy || state.unresolved));
316
+ let lastDirty: boolean | undefined;
317
+ let lastDirtyScope: typeof scope | undefined;
318
+ $effect(() => {
319
+ const origin = scope;
320
+ const dirty = form.isTainted() || uploadBlocked;
321
+ if (!currentScope(origin)) return;
322
+ if (lastDirtyScope !== origin) {
323
+ lastDirtyScope = origin;
324
+ lastDirty = undefined;
325
+ }
326
+ if (lastDirty === dirty) return;
327
+ lastDirty = dirty;
328
+ untrack(() => onDirtyChange?.(dirty));
329
+ });
330
+ // 记录/provider/auth 作用域变化时,只重挂载文件字段并丢弃旧回执。
331
+ let resetEpoch = $state(0);
332
+ const fileScopeKey = $derived({ scope, resetEpoch, upload, cancelUpload });
333
+ $effect(() => {
334
+ void fileScopeKey;
335
+ uploadStates = {};
336
+ });
192
337
  $effect(() => {
193
338
  void scope;
194
339
  submission = undefined;
195
340
  submitError = null;
196
341
  confirmOpen = false;
197
342
  pendingNavigation = null;
343
+ uploadStates = {};
344
+ });
345
+ const setUploadState = $derived.by(() => {
346
+ const origin = scope;
347
+ const originFileScope = fileScopeKey;
348
+ return (fieldKey: string, state: UploadState) => {
349
+ if (!currentScope(origin) || fileScopeKey !== originFileScope) return;
350
+ const previous = uploadStates[fieldKey];
351
+ if (previous?.busy === state.busy && previous?.unresolved === state.unresolved) return;
352
+ uploadStates = { ...uploadStates, [fieldKey]: state };
353
+ };
198
354
  });
199
355
  $effect(() => () => { mounted = false; submission = undefined; });
200
356
 
@@ -202,24 +358,64 @@
202
358
  return `${instanceId}-${resourceName}-${fieldKey}-error`;
203
359
  }
204
360
 
361
+ const focusableFieldSelector = [
362
+ 'input:not([type="hidden"])', 'textarea', 'select', 'button', '[tabindex]',
363
+ ].join(', ');
364
+ const richTextFieldSelector = '[contenteditable="true"][role="textbox"]';
365
+
366
+ function isActionableControl(element: HTMLElement): boolean {
367
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
368
+ if (!(parent instanceof HTMLFieldSetElement) || !parent.disabled) continue;
369
+ const legend = Array.from(parent.children).find(child => child instanceof HTMLLegendElement);
370
+ if (!legend?.contains(element)) return false;
371
+ }
372
+ return !element.hasAttribute('disabled')
373
+ && !element.matches('input[type="hidden"]')
374
+ && !element.matches(':disabled')
375
+ && !element.closest('[hidden], [inert], [aria-disabled="true"]');
376
+ }
377
+
378
+ function focusFirstActionableControl(field: Element, definition?: FieldDefinition): boolean {
379
+ // 仅默认渲染的顶层 richtext 字段优先正文;数组等容器仍按 DOM 顺序,
380
+ // 自定义 fieldRenderer 也不替宿主猜测正文入口。
381
+ if (!fieldRenderer && definition?.type === 'richtext') {
382
+ const richText = field.querySelector<HTMLElement>(richTextFieldSelector);
383
+ if (richText && isActionableControl(richText)) {
384
+ richText.focus();
385
+ if (document.activeElement === richText) return true;
386
+ }
387
+ }
388
+ const candidates = field.querySelectorAll<HTMLElement>(focusableFieldSelector);
389
+ for (const candidate of candidates) {
390
+ if (!isActionableControl(candidate)) continue;
391
+ candidate.focus();
392
+ if (document.activeElement === candidate) return true;
393
+ }
394
+ return false;
395
+ }
396
+
205
397
  function focusFirstInvalidField(): void {
206
- const firstInvalid = formElement?.querySelector<HTMLElement>('[aria-invalid="true"]');
207
- firstInvalid?.focus();
398
+ if (!formElement) return;
399
+ for (const field of formElement.querySelectorAll<HTMLElement>('[data-svadmin-form-field]')) {
400
+ const key = field.getAttribute('data-svadmin-field-key');
401
+ const error = key ? form.errors[key] : undefined;
402
+ const definition = key ? formFields.find(fieldDefinition => fieldDefinition.key === key) : undefined;
403
+ if (typeof error === 'string' && error.length > 0 && focusFirstActionableControl(field, definition)) return;
404
+ }
208
405
  }
209
406
 
210
407
  function focusField(fieldKey: string): void {
211
408
  const field = formElement?.querySelector<HTMLElement>(
212
- `[data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
409
+ `[data-svadmin-form-field][data-svadmin-field-key="${CSS.escape(fieldKey)}"]`,
213
410
  );
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();
411
+ if (field) focusFirstActionableControl(field, formFields.find(definition => definition.key === fieldKey));
217
412
  }
218
413
 
219
414
  const handleSubmit = $derived.by(() => {
220
415
  const current = scope;
221
416
  return async () => {
222
- if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting || submission) return;
417
+ if (!currentScope(current) || !allowed || isReadonly || !form.ready || form.submitting
418
+ || uploadBlocked || submission) return;
223
419
  const token = {};
224
420
  submission = token;
225
421
  submitError = null;
@@ -244,7 +440,15 @@
244
440
  const fieldChange = $derived.by(() => {
245
441
  const origin = scope;
246
442
  return (key: string) => (value: unknown) => {
247
- if (currentScope(origin)) form.setFieldValue(key, value);
443
+ if (!currentScope(origin) || !form.ready || !allowed || isReadonly) return;
444
+ const previous = renderedDraft.values;
445
+ form.setFieldValue(key, withoutArrayFieldMetadata(value));
446
+ if (currentScope(origin) && authoritativeDraft.ok) {
447
+ acceptedDraft = {
448
+ snapshot: authoritativeDraft,
449
+ values: { ...previous, [key]: value },
450
+ };
451
+ }
248
452
  };
249
453
  });
250
454
  const retry = $derived.by(() => {
@@ -252,6 +456,32 @@
252
456
  const refetch = form.query.refetch;
253
457
  return () => { if (currentScope(origin)) void refetch().catch(() => {}); };
254
458
  });
459
+ const queryClient = useQueryClient();
460
+ function refreshPermission() {
461
+ const resource = resourceName;
462
+ void queryClient.invalidateQueries({
463
+ predicate: query => {
464
+ const parsed = parseQueryKey(query.queryKey);
465
+ return parsed?.kind === 'access' && parsed.resource === resource;
466
+ },
467
+ }).catch(() => {});
468
+ }
469
+
470
+ // 显式 reset 入口:宿主通过 bind:this 调用,恢复 Core 初始草稿并清理本实例
471
+ // 的提交令牌/提示;不触发导航,也不改变只读或未授权状态。
472
+ export function reset(): void {
473
+ if (!allowed || isReadonly) return;
474
+ submission = undefined;
475
+ submitError = null;
476
+ // reset 后基线不再 tainted,旧的关闭保护与其待执行导航必须一起退休,
477
+ // 否则后续确认会执行 reset 前捕获的导航。
478
+ confirmOpen = false;
479
+ pendingNavigation = null;
480
+ uploadStates = {};
481
+ // reset token 重挂载文件字段:旧 FileUpload 会按 unmount 回收在途上传。
482
+ resetEpoch += 1;
483
+ form.reset();
484
+ }
255
485
 
256
486
  const pageTitle = $derived(
257
487
  mode === 'create'
@@ -272,7 +502,7 @@
272
502
  return (fn: () => void) => {
273
503
  if (!currentScope(origin)) return;
274
504
  if (form.submitting) return;
275
- if (form.isTainted()) {
505
+ if (form.isTainted() || uploadBlocked) {
276
506
  pendingNavigation = () => { if (currentScope(origin)) fn(); };
277
507
  confirmOpen = true;
278
508
  } else {
@@ -343,7 +573,14 @@
343
573
  </div>
344
574
  </div>
345
575
  {:else if !allowed}
346
- <p role="alert">{i18n.t('common.operationFailed')}</p>
576
+ {#if permissionError}
577
+ <p role="alert">{i18n.t('common.permissionUnavailable')}</p>
578
+ <Button type="button" variant="outline" onclick={refreshPermission}>
579
+ {i18n.t('common.retry')}
580
+ </Button>
581
+ {:else}
582
+ <p role="alert">{i18n.t('common.permissionDenied')}</p>
583
+ {/if}
347
584
  {:else if !form.ready && form.error}
348
585
  <p role="alert">{i18n.t('common.operationFailed')}</p>
349
586
  <Button type="button" variant="outline" onclick={retry}>
@@ -382,7 +619,7 @@
382
619
  <Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-7a9aabfcd059 svadmin-u-9fcd8a13827e svadmin-u-050494726fba svadmin-u-9335c39f6eff">
383
620
  <div class={gridClass}>
384
621
  {#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')}>
622
+ <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
623
  {#if fieldRenderer}
387
624
  {@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
388
625
  {:else}
@@ -393,6 +630,10 @@
393
630
  invalid={!!form.errors[field.key]}
394
631
  {...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
395
632
  disabled={isReadonly}
633
+ fileReferenceMode
634
+ {fileScopeKey}
635
+ {...definedOptions({ upload, cancelUpload })}
636
+ onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
396
637
  />
397
638
  {/if}
398
639
  {#if form.errors[field.key]}
@@ -409,7 +650,7 @@
409
650
  <Card.Content class="svadmin-u-f0faeb26d656 svadmin-u-52be28846b5f svadmin-u-9fcd8a13827e svadmin-u-7a9aabfcd059 svadmin-u-0a58453f3755 svadmin-u-050494726fba">
410
651
  <div class={gridClass} data-svadmin-form-grid data-columns={columns}>
411
652
  {#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')}>
653
+ <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
654
  {#if fieldRenderer}
414
655
  {@render fieldRenderer({ field, value: renderedDraft.values[field.key], onchange: fieldChange(field.key) })}
415
656
  {:else}
@@ -420,6 +661,10 @@
420
661
  invalid={!!form.errors[field.key]}
421
662
  {...definedOptions({ "errorId": form.errors[field.key] ? fieldErrorId(field.key) : undefined })}
422
663
  disabled={isReadonly}
664
+ fileReferenceMode
665
+ {fileScopeKey}
666
+ {...definedOptions({ upload, cancelUpload })}
667
+ onUploadStateChange={(state: UploadState) => setUploadState(field.key, state)}
423
668
  />
424
669
  {/if}
425
670
  {#if form.errors[field.key]}
@@ -434,9 +679,9 @@
434
679
 
435
680
  <div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-1004c0c3954c">
436
681
  {#if formActions && !isReadonly}
437
- {@render formActions({ isLoading: form.submitting, onSubmit: handleSubmit })}
682
+ {@render formActions({ isLoading: form.submitting || uploadPending, isDisabled: form.submitting || uploadBlocked, onSubmit: handleSubmit })}
438
683
  {:else if !isReadonly}
439
- <Button type="submit" size="default" disabled={form.submitting}>
684
+ <Button type="submit" size="default" disabled={form.submitting || uploadBlocked}>
440
685
  {#if form.submitting}
441
686
  <Loader2 class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-afbdd13a380e" data-icon="inline-start" aria-hidden="true" />
442
687
  {: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;