@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
@@ -20,6 +20,7 @@
20
20
  } from '@tanstack/svelte-table';
21
21
  import type { Column, Header, Row, TableFeatures } from '@tanstack/table-core';
22
22
  import { createAtom, useSelector } from '@tanstack/svelte-store';
23
+ import { useQueryClient } from '@tanstack/svelte-query';
23
24
  import {
24
25
  column_getCanSort,
25
26
  column_getSize,
@@ -37,7 +38,7 @@
37
38
  cell_getValue,
38
39
  } from '@tanstack/table-core/static-functions';
39
40
 
40
- import { captureAdminContext, captureAuthSession, useGetIdentity, getContractFormFields, useNavigation, useParsed, useResourceContract, useList, useDeleteMany, downloadData, type ExportFormat, type TaskProvider } from '@svadmin/core';
41
+ import { captureAdminContext, captureAuthSession, useGetIdentity, getContractFormFields, useNavigation, useParsed, useResourceContract, useList, useDeleteMany, downloadData, type ExportFormat, type TaskProvider, parseQueryKey } from '@svadmin/core';
41
42
  import { snapshotPlainData, parseContractRouteId, formatContractRouteId } from '@svadmin/core/schema';
42
43
  import {
43
44
  checkedTableRows,
@@ -56,7 +57,9 @@
56
57
  import { readURLState, writeURLState } from '@svadmin/core';
57
58
  import { useTranslation } from '@svadmin/core/i18n';
58
59
  import { createSavedListViews } from './saved-list-views.svelte.js';
59
- import { createListState } from './list-state.svelte.js';
60
+ import {
61
+ captureSavedListViewState, cloneListStateSnapshot, createListState, type ListStateSnapshot,
62
+ } from './list-state.svelte.js';
60
63
  import { createListSelection } from './list-selection.svelte.js';
61
64
  import { createListColumnPreferences } from './list-column-preferences.svelte.js';
62
65
  import { createListDeletion } from './list-deletion.svelte.js';
@@ -87,7 +90,10 @@
87
90
  Eye, Copy, RefreshCw, Rows, X, Bookmark, Check
88
91
  } from '@lucide/svelte';
89
92
  import ConfirmDialog from './ConfirmDialog.svelte';
93
+ import BatchActionBar from './BatchActionBar.svelte';
90
94
  import RecordDetailDrawer from './RecordDetailDrawer.svelte';
95
+ import DetailWorkspaceSections from './DetailWorkspaceSections.svelte';
96
+ import type { DetailWorkspaceSectionsConfig } from './detail-workspace.js';
91
97
  import QuickEditDrawer from './QuickEditDrawer.svelte';
92
98
  import RecordRowActions from './RecordRowActions.svelte';
93
99
  import CanAccess from './CanAccess.svelte';
@@ -136,6 +142,10 @@
136
142
  pagination?: { current: number; pageSize: number };
137
143
  /** Externally controlled sorters */
138
144
  sorters?: Sort[];
145
+ /** 初次挂载时恢复的查询快照;优先级低于受控分页/排序和已启用的 URL 状态。 */
146
+ initialListState?: ListStateSnapshot | undefined;
147
+ /** 查询快照变化回调;搜索在 debounce 应用后才触发。 */
148
+ onListStateChange?: ((state: ListStateSnapshot) => void) | undefined;
139
149
  /** 批量操作必须按 selection.scope 区分明确 ID 与全部查询结果。 */
140
150
  batchActions?: Snippet<[{ selectedIds: (string | number)[]; selection: BatchSelection }]>;
141
151
  /** Summary row rendered at table footer */
@@ -151,6 +161,8 @@
151
161
  exportTaskIdempotencyKey?: string;
152
162
  exportFormat?: ExportFormat;
153
163
  exportMaxItemCount?: number;
164
+ /** Optional record-scoped detail workspace sections. Audit uses the existing AuditLogProvider. */
165
+ detailWorkspace?: DetailWorkspaceSectionsConfig;
154
166
  }
155
167
 
156
168
  let {
@@ -176,6 +188,8 @@
176
188
  summary,
177
189
  pagination: externalPagination,
178
190
  sorters: externalSorters,
191
+ initialListState,
192
+ onListStateChange,
179
193
  deleteVariables,
180
194
  savedViewProvider,
181
195
  applyRemoteDefaultView = false,
@@ -184,11 +198,13 @@
184
198
  exportTaskIdempotencyKey,
185
199
  exportFormat = 'csv',
186
200
  exportMaxItemCount,
201
+ detailWorkspace,
187
202
  }: Props = $props();
188
203
 
189
204
  let densityOverride = $state<'compact' | 'comfortable' | undefined>(undefined);
190
205
  const currentDensity = $derived(densityOverride ?? density);
191
206
  const adminContext = captureAdminContext();
207
+ const queryClient = useQueryClient();
192
208
  const parsed = useParsed();
193
209
  const navigation = useNavigation();
194
210
 
@@ -251,6 +267,26 @@
251
267
  || urlState.search !== undefined
252
268
  || urlState.filters !== undefined
253
269
  ));
270
+ const initialListStateSnapshot = untrack(() => initialListState
271
+ ? cloneListStateSnapshot(initialListState)
272
+ : undefined);
273
+ const initialPreferenceScope: ListPreferenceScope = untrack(() => listPreferenceScope);
274
+ const initialPreferenceSessionKey = untrack(() => preferenceSession.cacheKey);
275
+ let initialListStateRetired = !initialListStateSnapshot;
276
+ let initialIdentityHydrationPending = !!initialListStateSnapshot
277
+ && initialPreferenceScope.identityKey?.startsWith('unresolved:') === true;
278
+
279
+ function sameScopeExceptIdentity(left: ListPreferenceScope, right: ListPreferenceScope): boolean {
280
+ return left.resourceName === right.resourceName
281
+ && left.providerName === right.providerName
282
+ && left.tenantIdentity === right.tenantIdentity;
283
+ }
284
+
285
+ function initialListStateApplies(scope: ListPreferenceScope): boolean {
286
+ return !!initialListStateSnapshot && !initialListStateRetired
287
+ && sameScopeExceptIdentity(scope, initialPreferenceScope);
288
+ }
289
+
254
290
  const savedViewModel = createSavedListViews({
255
291
  scope: () => listPreferenceScope,
256
292
  columns: () => savedViewColumnIds,
@@ -258,7 +294,9 @@
258
294
  isScopeLoaded: preferenceScopeIsLoaded,
259
295
  readPreference: readScopedPreference,
260
296
  hasExplicitURLState,
261
- canApplyRemoteDefault: () => applyRemoteDefaultView && !externalPagination && !externalSorters,
297
+ initialStateOverridesSavedView: !!initialListStateSnapshot,
298
+ canApplyRemoteDefault: () => applyRemoteDefaultView && !externalPagination && !externalSorters
299
+ && !initialListStateApplies(listPreferenceScope),
262
300
  captureState: () => {
263
301
  listState.flushSearch();
264
302
  return getCurrentSavedViewState();
@@ -275,6 +313,7 @@
275
313
  // The table can stay mounted while the surrounding tenant/provider changes.
276
314
  // Keep persistence effects paused until the new scope has been loaded.
277
315
  let loadedPreferenceScopeId = $state(untrack(() => listPreferenceScopeId(listPreferenceScope)));
316
+ const queryScopeReady = $derived(loadedPreferenceScopeId === listPreferenceScopeId(listPreferenceScope));
278
317
 
279
318
  // Snapshot resource values for initial state (untrack to avoid reactive tracking)
280
319
  const storedPageSize = parseInt(readLocalPreference('svadmin-default-page-size') ?? '', 10);
@@ -286,10 +325,12 @@
286
325
  externalPagination: () => externalPagination,
287
326
  externalSorters: () => externalSorters,
288
327
  initialURLState: urlState,
328
+ initialListState: initialListStateSnapshot,
289
329
  initialViewState,
290
330
  syncWithLocation: () => syncWithLocation,
291
331
  writeURLState: (state) => writeURLState(state, adminContext),
292
332
  onDirty: markSavedViewDirty,
333
+ onStateChange: (state) => onListStateChange?.(state),
293
334
  });
294
335
  const pagination = $derived(listState.pagination);
295
336
  const sorters = $derived(listState.sorters);
@@ -325,7 +366,9 @@
325
366
  const listResult = useList({
326
367
  get resource() { void activeRendering; return binding.resource; },
327
368
  get dataProviderName() { return binding.dataProviderName; },
328
- get queryOptions() { return { enabled: canRead }; },
369
+ errorNotification: false,
370
+ // 作用域恢复完成前不发送查询,避免把旧作用域快照作为新作用域的首帧请求。
371
+ get queryOptions() { return { enabled: canRead && queryScopeReady }; },
329
372
  get pagination() { return queryPagination; },
330
373
  get sorters() { return querySorters; },
331
374
  get filters() { return queryFilters; },
@@ -500,15 +543,14 @@
500
543
  });
501
544
 
502
545
  function getCurrentSavedViewState(): SavedListViewState {
503
- return {
504
- ...listState.captureState(),
505
- ...columnPreferences.captureState(),
506
- };
546
+ return captureSavedListViewState(
547
+ listState.captureState(), columnPreferences.captureState(), initPageSize,
548
+ );
507
549
  }
508
550
 
509
551
  function applySavedViewState(state: SavedListViewState): void {
510
552
  listState.applyState(state);
511
- sortingAtom.set(state.sorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
553
+ sortingAtom.set(listState.sorters.map((sorter) => ({ id: sorter.field, desc: sorter.order === 'desc' })));
512
554
  columnPreferences.applyState(state);
513
555
  selectionModel.reset();
514
556
  }
@@ -525,10 +567,30 @@
525
567
  const scopeId = listPreferenceScopeId(scope);
526
568
  if (scopeId === loadedPreferenceScopeId) return;
527
569
 
570
+ if (initialListStateSnapshot) {
571
+ const identityHydration = initialIdentityHydrationPending
572
+ && sameScopeExceptIdentity(scope, initialPreferenceScope)
573
+ && preferenceSession.cacheKey === initialPreferenceSessionKey
574
+ && scope.identityKey !== undefined
575
+ && !scope.identityKey.startsWith('unresolved:');
576
+ if (identityHydration) {
577
+ initialIdentityHydrationPending = false;
578
+ } else {
579
+ initialListStateRetired = true;
580
+ initialIdentityHydrationPending = false;
581
+ }
582
+ }
583
+
528
584
  const activeSavedView = savedViewModel.restoreScope(scope);
529
585
  const restoredColumns = columnPreferences.readScope(scope);
530
586
 
531
- if (!hasExplicitURLState && activeSavedView) {
587
+ if (!hasExplicitURLState && initialListStateApplies(scope)) {
588
+ // 挂载快照已由 createListState 应用;首次身份 hydrate 只保留用户编辑,不重放快照。
589
+ // 初始快照优先于旧活动视图时取消活动标记,但不删除保存视图或写入其他作用域。
590
+ savedViewModel.clearActiveSavedView();
591
+ columnPreferences.applyState(restoredColumns);
592
+ selectionModel.reset();
593
+ } else if (!hasExplicitURLState && activeSavedView) {
532
594
  applySavedView(activeSavedView);
533
595
  } else {
534
596
  if (!hasExplicitURLState) resetToDefaultListState();
@@ -749,6 +811,8 @@
749
811
  // ─── Confirm dialog ───────────────────────────────────────────
750
812
  let operationError = $state<string | null>(null);
751
813
  let detailOpenedInHistory = $state(false);
814
+ let detailOpenedId = $state<string | number | undefined>();
815
+ let detailRouteChangedInHistory = $state(false);
752
816
  let quickEditId = $state<string | number | undefined>();
753
817
  const tableScope = $derived({
754
818
  contract: binding.resource, resourceName, provider: adminContext.providers?.[binding.dataProviderName],
@@ -788,6 +852,8 @@
788
852
  selectionModel.reset();
789
853
  expandedAtom.set({});
790
854
  detailOpenedInHistory = false;
855
+ detailOpenedId = undefined;
856
+ detailRouteChangedInHistory = false;
791
857
  localDetailId = undefined;
792
858
  quickEditId = undefined;
793
859
  const queryScopeChanged = previousTableScope.contract !== scope.contract
@@ -809,16 +875,35 @@
809
875
  if (!canRead || !canShow) return;
810
876
  if (!syncWithLocation) { localDetailId = id; return; }
811
877
  detailOpenedInHistory = true;
878
+ detailOpenedId = id;
879
+ detailRouteChangedInHistory = false;
812
880
  writeURLState({ detailId: formatContractRouteId(binding.resource, id) }, adminContext, 'push');
813
881
  }
882
+ $effect.pre(() => {
883
+ if (!detailOpenedInHistory || detailOpenedId == null || detailRecordId == null) return;
884
+ if (String(detailRecordId) !== String(detailOpenedId)) detailRouteChangedInHistory = true;
885
+ });
814
886
  function closeDetail(): void {
815
887
  if (!syncWithLocation) { localDetailId = undefined; return; }
816
888
  if (detailOpenedInHistory && adminContext.routerProvider) {
889
+ const routeChanged = detailRouteChangedInHistory;
817
890
  detailOpenedInHistory = false;
891
+ detailOpenedId = undefined;
892
+ detailRouteChangedInHistory = false;
893
+ // The drawer can move to another record without unmounting (next/previous).
894
+ // Once that happened, the top history entry may be a detail route even if
895
+ // the current record is the originally opened one (A -> B -> A), so a single
896
+ // back() could reopen another record instead of closing.
897
+ if (routeChanged) {
898
+ writeURLState({ detailId: undefined }, adminContext);
899
+ return;
900
+ }
818
901
  adminContext.routerProvider.back();
819
902
  return;
820
903
  }
821
904
  detailOpenedInHistory = false;
905
+ detailOpenedId = undefined;
906
+ detailRouteChangedInHistory = false;
822
907
  writeURLState({ detailId: undefined }, adminContext);
823
908
  }
824
909
  // ─── CSV Export ───────────────────────────────────────────────
@@ -843,6 +928,15 @@
843
928
  function refreshList() {
844
929
  if (canRead && !query.isFetching) void listResult.refetch();
845
930
  }
931
+ function refreshPermission() {
932
+ const resource = resourceName;
933
+ void queryClient.invalidateQueries({
934
+ predicate: query => {
935
+ const parsed = parseQueryKey(query.queryKey);
936
+ return parsed?.kind === 'access' && parsed.resource === resource;
937
+ },
938
+ }).catch(() => {});
939
+ }
846
940
  const gridRowIndex = $derived(new Map(tableView.rows.map(row => [rowIdValue(row), row])));
847
941
  const gridColumns = $derived.by(() => {
848
942
  void tableColumnOrder.current; void tableColumnVisibility.current;
@@ -1108,20 +1202,16 @@
1108
1202
  </div>
1109
1203
  {/if}
1110
1204
 
1111
- <!-- Selection Banner (Enterprise Batch Actions) -->
1112
- {#if selectedCount > 0 || allMatchingSelected}
1113
- <div
1114
- class="svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-1004c0c3954c svadmin-u-ca6bcd4b6f3f svadmin-u-a6afccfc915b svadmin-u-375dc44df6e9 svadmin-u-e0d9cc7f0647 svadmin-u-03b4dd7f172b svadmin-u-5f22e64f2282 svadmin-u-fc7473ca09eb svadmin-u-d4108abe6359 svadmin-u-40137e897961 fade-in svadmin-u-625a4c3fbeb2 svadmin-u-259ce51fc8f3 svadmin-u-020ba687fa12 svadmin-u-9f76a62f4f44 svadmin-u-3b9871a0bf93"
1115
- aria-label={selectionLabel}
1116
- data-svadmin-batch-toolbar
1117
- >
1118
- <div class="svadmin-u-60fbb7713999 svadmin-u-7e0b7cdf1a94 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4 svadmin-u-fc7473ca09eb">
1119
- <span class="svadmin-u-2689f3958069 svadmin-u-d4108abe6359 svadmin-u-3032cae0badb">{selectionLabel}</span>
1120
- {#if deleteManyMutation.isPending}
1121
- <span class="svadmin-u-bfa603190748" role="status">{i18n.t("common.processing")}</span>
1122
- {/if}
1123
- </div>
1124
- <div class="svadmin-u-60fbb7713999 svadmin-u-1eb5c6df38c1 svadmin-u-3960ffc248d9 svadmin-u-77a2a20e90d4">
1205
+ {#if selectedCount > 0 || allMatchingSelected || (deleteRequest?.batch && deleteManyMutation.isPending)}
1206
+ <div data-svadmin-batch-toolbar>
1207
+ <BatchActionBar
1208
+ {selectedCount}
1209
+ selectedLabel={selectionLabel}
1210
+ ariaLabel={selectionLabel}
1211
+ forceVisible={allMatchingSelected}
1212
+ pending={deleteManyMutation.isPending}
1213
+ onclear={clearSelection}
1214
+ >
1125
1215
  {#if batchActions && selectionReady && selectedCount > 0}
1126
1216
  {@render batchActions({ selectedIds: [...selectedIds], selection: batchSelectionSnapshot() })}
1127
1217
  {/if}
@@ -1142,16 +1232,7 @@
1142
1232
  <Trash2 class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" data-icon="inline-start" aria-hidden="true" /> {i18n.t("common.batchDelete", { count: selectedCount })}
1143
1233
  </Button>
1144
1234
  {/if}
1145
- <Button
1146
- variant="ghost"
1147
- size="sm"
1148
- class="svadmin-u-ed8a5df7b2fb svadmin-u-359090c2d529 svadmin-u-bfa603190748 svadmin-u-ea7b2e9e070e"
1149
- disabled={deleteManyMutation.isPending}
1150
- onclick={clearSelection}
1151
- >
1152
- {i18n.t("common.clearSelection")}
1153
- </Button>
1154
- </div>
1235
+ </BatchActionBar>
1155
1236
  </div>
1156
1237
  {/if}
1157
1238
 
@@ -1305,6 +1386,9 @@
1305
1386
  <div>
1306
1387
  <h4 class="svadmin-u-2689f3958069 svadmin-u-fc7473ca09eb">{i18n.t("common.savedViews")}</h4>
1307
1388
  <p class="svadmin-u-b6b02c0ebef6 svadmin-u-359090c2d529 svadmin-u-bfa603190748">{i18n.t("common.savedViewsHint")}</p>
1389
+ {#if savedViewModel.staleSavedViewColumns.length > 0}
1390
+ <p role="status">{i18n.t('common.savedViewStaleFields', { count: savedViewModel.staleSavedViewColumns.length })}</p>
1391
+ {/if}
1308
1392
  </div>
1309
1393
  {#if savedViewModel.remoteViewsLoading}
1310
1394
  <p role="status">{i18n.t('common.loading')}</p>
@@ -1610,8 +1694,13 @@
1610
1694
  </div>
1611
1695
  {/each}
1612
1696
  </div>
1697
+ {:else if listPermission.isError}
1698
+ <p role="alert">{i18n.t('common.permissionUnavailable')}</p>
1699
+ <Button type="button" variant="outline" onclick={refreshPermission}>
1700
+ {i18n.t('common.retry')}
1701
+ </Button>
1613
1702
  {:else if !canRead}
1614
- <p role="alert">{i18n.t('common.operationFailed')}</p>
1703
+ <p role="alert">{i18n.t('common.permissionDenied')}</p>
1615
1704
  {:else if query.error || query.isError || !pageRecords.ok}
1616
1705
  <DataState
1617
1706
  state="error"
@@ -1958,6 +2047,16 @@
1958
2047
  oncancel={deletionModel.cancel}
1959
2048
  />
1960
2049
 
2050
+ {#snippet workspaceSections()}
2051
+ {#if detailRecordId != null && detailWorkspace}
2052
+ <DetailWorkspaceSections
2053
+ {resourceName}
2054
+ recordId={detailRecordId}
2055
+ sections={detailWorkspace}
2056
+ />
2057
+ {/if}
2058
+ {/snippet}
2059
+
1961
2060
  {#if detailRecordId != null}
1962
2061
  <RecordDetailDrawer
1963
2062
  rendering={activeRendering}
@@ -1965,6 +2064,7 @@
1965
2064
  open={detailOpen}
1966
2065
  recordId={detailRecordId}
1967
2066
  onClose={closeDetail}
2067
+ {...definedOptions({ extraSections: detailWorkspace ? workspaceSections : undefined })}
1968
2068
  />
1969
2069
  {/if}
1970
2070
 
@@ -3,7 +3,9 @@ import type { AutoTableGridState } from './auto-table-grid.js';
3
3
  import { type ExportFormat, type TaskProvider } from '@svadmin/core';
4
4
  import { type BatchSelection } from './table-contract';
5
5
  import type { BaseRecord, FieldDefinition, Sort } from '@svadmin/core';
6
+ import { type ListStateSnapshot } from './list-state.svelte.js';
6
7
  import { type SavedListViewProvider } from './saved-list-views.js';
8
+ import type { DetailWorkspaceSectionsConfig } from './detail-workspace.js';
7
9
  import type { Snippet } from 'svelte';
8
10
  interface Props {
9
11
  resourceName: string;
@@ -54,6 +56,10 @@ interface Props {
54
56
  };
55
57
  /** Externally controlled sorters */
56
58
  sorters?: Sort[];
59
+ /** 初次挂载时恢复的查询快照;优先级低于受控分页/排序和已启用的 URL 状态。 */
60
+ initialListState?: ListStateSnapshot | undefined;
61
+ /** 查询快照变化回调;搜索在 debounce 应用后才触发。 */
62
+ onListStateChange?: ((state: ListStateSnapshot) => void) | undefined;
57
63
  /** 批量操作必须按 selection.scope 区分明确 ID 与全部查询结果。 */
58
64
  batchActions?: Snippet<[{
59
65
  selectedIds: (string | number)[];
@@ -76,6 +82,8 @@ interface Props {
76
82
  exportTaskIdempotencyKey?: string;
77
83
  exportFormat?: ExportFormat;
78
84
  exportMaxItemCount?: number;
85
+ /** Optional record-scoped detail workspace sections. Audit uses the existing AuditLogProvider. */
86
+ detailWorkspace?: DetailWorkspaceSectionsConfig;
79
87
  }
80
88
  declare const AutoTable: import("svelte").Component<Props, {}, "">;
81
89
  type AutoTable = ReturnType<typeof AutoTable>;
@@ -4,12 +4,20 @@
4
4
  import { Button } from './ui/button/index.js';
5
5
  import { Badge } from './ui/badge/index.js';
6
6
  import { cn } from '../utils.js';
7
+ import { useTranslation } from '@svadmin/core/i18n';
8
+
9
+ const i18n = useTranslation();
7
10
 
8
11
  interface Props {
9
12
  selectedCount?: number;
10
13
  totalCount?: number;
11
14
  selectedLabel?: string;
12
15
  clearLabel?: string;
16
+ ariaLabel?: string;
17
+ pending?: boolean;
18
+ pendingLabel?: string;
19
+ /** 全量匹配等选择模式可在零计数时保留清除入口。 */
20
+ forceVisible?: boolean;
13
21
  variant?: 'default' | 'floating' | 'subtle';
14
22
  onclear?: () => void;
15
23
  actions?: Snippet;
@@ -21,7 +29,11 @@
21
29
  selectedCount = 0,
22
30
  totalCount,
23
31
  selectedLabel,
24
- clearLabel = 'Clear',
32
+ clearLabel,
33
+ ariaLabel,
34
+ pending = false,
35
+ pendingLabel,
36
+ forceVisible = false,
25
37
  variant = 'default',
26
38
  onclear,
27
39
  actions,
@@ -29,17 +41,26 @@
29
41
  class: className = '',
30
42
  }: Props = $props();
31
43
 
32
- const isVisible = $derived(selectedCount > 0);
44
+ const isVisible = $derived(selectedCount > 0 || pending || forceVisible);
45
+ const clearText = $derived(clearLabel ?? i18n.t('common.clearSelection'));
46
+ const regionLabel = $derived(ariaLabel ?? i18n.t('common.batchActions'));
47
+ const busyText = $derived(pendingLabel ?? i18n.t('common.processing'));
33
48
  const displayText = $derived.by(() => {
34
49
  if (selectedLabel) {
35
50
  return selectedLabel.replace('{count}', String(selectedCount));
36
51
  }
37
52
  return totalCount !== undefined
38
- ? `${selectedCount} of ${totalCount} selected`
39
- : `${selectedCount} selected`;
53
+ ? i18n.t('common.selectedOfTotal', { count: selectedCount, total: totalCount })
54
+ : i18n.t('common.selectedCount', { count: selectedCount });
40
55
  });
56
+
57
+ function handleClear() {
58
+ if (!pending) onclear?.();
59
+ }
41
60
  </script>
42
61
 
62
+ <span class="svadmin-sr-only" role="status" aria-live="polite" aria-atomic="true">{pending ? busyText : ''}</span>
63
+
43
64
  {#if isVisible}
44
65
  {#if variant === 'floating'}
45
66
  <div
@@ -48,35 +69,39 @@
48
69
  className
49
70
  )}
50
71
  role="region"
51
- aria-label="Batch Actions"
72
+ aria-label={regionLabel}
73
+ aria-busy={pending || undefined}
52
74
  >
53
75
  <div class="svadmin-batch-action-bar__selection">
54
76
  <CheckCircle2 class="svadmin-batch-action-bar__icon" aria-hidden="true" />
55
77
  <span class="svadmin-batch-action-bar__label">{displayText}</span>
78
+ {#if pending}<span aria-hidden="true">{busyText}</span>{/if}
56
79
  </div>
57
80
 
58
81
  {#if actions}
59
- <div class="svadmin-batch-action-bar__actions">
82
+ <div class="svadmin-batch-action-bar__actions" inert={pending}>
60
83
  {@render actions()}
61
84
  </div>
62
85
  {/if}
63
86
 
64
87
  {#if children}
65
- <div class="svadmin-batch-action-bar__actions">
88
+ <div class="svadmin-batch-action-bar__actions" inert={pending}>
66
89
  {@render children()}
67
90
  </div>
68
91
  {/if}
69
92
 
70
93
  {#if onclear}
71
94
  <Button
95
+ type="button"
72
96
  variant="ghost"
73
97
  size="sm"
74
98
  class="svadmin-batch-action-bar__clear"
75
- onclick={onclear}
76
- aria-label={clearLabel}
99
+ onclick={handleClear}
100
+ aria-label={clearText}
101
+ disabled={pending}
77
102
  >
78
103
  <X class="svadmin-batch-action-bar__clear-icon" aria-hidden="true" />
79
- {clearLabel}
104
+ {clearText}
80
105
  </Button>
81
106
  {/if}
82
107
  </div>
@@ -88,25 +113,28 @@
88
113
  className
89
114
  )}
90
115
  role="region"
91
- aria-label="Batch Actions"
116
+ aria-label={regionLabel}
117
+ aria-busy={pending || undefined}
92
118
  >
93
119
  <div class="svadmin-batch-action-bar__selection">
94
120
  <Badge variant="secondary" class="svadmin-batch-action-bar__count">
95
121
  {selectedCount}
96
122
  </Badge>
97
123
  <span class="svadmin-batch-action-bar__label">{displayText}</span>
124
+ {#if pending}<span aria-hidden="true">{busyText}</span>{/if}
98
125
  {#if onclear}
99
126
  <button
100
127
  type="button"
101
128
  class="svadmin-batch-action-bar__clear"
102
- onclick={onclear}
129
+ onclick={handleClear}
130
+ disabled={pending}
103
131
  >
104
- {clearLabel}
132
+ {clearText}
105
133
  </button>
106
134
  {/if}
107
135
  </div>
108
136
 
109
- <div class="svadmin-batch-action-bar__actions">
137
+ <div class="svadmin-batch-action-bar__actions" inert={pending}>
110
138
  {#if actions}
111
139
  {@render actions()}
112
140
  {/if}
@@ -4,6 +4,11 @@ interface Props {
4
4
  totalCount?: number;
5
5
  selectedLabel?: string;
6
6
  clearLabel?: string;
7
+ ariaLabel?: string;
8
+ pending?: boolean;
9
+ pendingLabel?: string;
10
+ /** 全量匹配等选择模式可在零计数时保留清除入口。 */
11
+ forceVisible?: boolean;
7
12
  variant?: 'default' | 'floating' | 'subtle';
8
13
  onclear?: () => void;
9
14
  actions?: Snippet;
@@ -153,9 +153,11 @@
153
153
  {/if}
154
154
 
155
155
  {#if extraSections && canReadRecord && query.isSuccess}
156
- <section data-svadmin-detail-extensions aria-label={i18n.t('common.additionalDetails')}>
157
- {@render extraSections()}
158
- </section>
156
+ {#key `${resourceName}:${recordId}`}
157
+ <section data-svadmin-detail-extensions aria-label={i18n.t('common.additionalDetails')}>
158
+ {@render extraSections()}
159
+ </section>
160
+ {/key}
159
161
  {/if}
160
162
 
161
163
  {#snippet footer()}
@@ -6,6 +6,8 @@
6
6
  import { createTaskAction } from './task-action.svelte.js';
7
7
 
8
8
  const i18n = useTranslation();
9
+ const instanceId = $props.id();
10
+ const errorId = `${instanceId}-error`;
9
11
 
10
12
  let {
11
13
  taskId,
@@ -37,7 +39,9 @@
37
39
  });
38
40
  </script>
39
41
 
40
- <Button {variant} {size} onclick={action.run} disabled={!action.available || action.pending}>
42
+ <span class="task-action-feedback">
43
+ <Button {variant} {size} onclick={action.run} disabled={!action.available || action.pending}
44
+ aria-describedby={action.failed && !onError ? errorId : undefined}>
41
45
  {#if action.pending}
42
46
  <Loader2 class="svadmin-u-82cc6c6581cd svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-afbdd13a380e" />
43
47
  {:else}
@@ -49,3 +53,12 @@
49
53
  {i18n.t('common.cancel')}
50
54
  {/if}
51
55
  </Button>
56
+ {#if action.failed && !onError}
57
+ <span id={errorId} role="alert">{i18n.t('task.actionFailed')}</span>
58
+ {/if}
59
+ </span>
60
+
61
+ <style>
62
+ .task-action-feedback { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 0; max-width: 16rem; }
63
+ .task-action-feedback [role="alert"] { color: var(--destructive); font-size: 12px; line-height: 16px; white-space: normal; overflow-wrap: anywhere; }
64
+ </style>