@svadmin/ui 0.88.0 → 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
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { onDestroy } from 'svelte';
2
3
  import { X, Pin, RefreshCw, XCircle } from '@lucide/svelte';
3
4
  import { Button } from './ui/button/index.js';
4
5
  import { cn } from '../utils.js';
@@ -10,102 +11,558 @@
10
11
  closable?: boolean;
11
12
  pinned?: boolean;
12
13
  icon?: string;
14
+ /** 消费方声明该标签存在未保存草稿,关闭保护由 onbeforeclose 决定。 */
15
+ dirty?: boolean;
16
+ }
17
+
18
+ export type WorkspaceTabOpenStatus = 'opened' | 'selected' | 'rejected';
19
+
20
+ export type WorkspaceTabOpenRejectReason =
21
+ | 'invalid-max-tabs'
22
+ | 'duplicate-id-path-mismatch'
23
+ | 'limit-reached'
24
+ | 'eviction-declined'
25
+ | 'operation-in-flight'
26
+ | 'stale-state'
27
+ | 'unmounted';
28
+
29
+ export interface WorkspaceTabOpenAcceptedResult {
30
+ status: 'opened' | 'selected';
31
+ tab: WorkspaceTab;
32
+ /** 本次成功打开时被确认淘汰的标签 id,按淘汰顺序排列。 */
33
+ evictedTabIds: string[];
34
+ }
35
+
36
+ export interface WorkspaceTabOpenRejectedResult {
37
+ status: 'rejected';
38
+ reason: WorkspaceTabOpenRejectReason;
39
+ tab: WorkspaceTab;
40
+ }
41
+
42
+ export type WorkspaceTabOpenResult =
43
+ | WorkspaceTabOpenAcceptedResult
44
+ | WorkspaceTabOpenRejectedResult;
45
+
46
+ export interface WorkspaceTabLimitReachedDetail {
47
+ tab: WorkspaceTab;
48
+ maxTabs: number;
49
+ /** 当前非固定标签数量,固定标签不计入额度。 */
50
+ quota: number;
51
+ reason: 'no-safe-candidate' | 'eviction-declined';
13
52
  }
14
53
 
15
54
  interface Props {
16
55
  tabs?: WorkspaceTab[];
17
56
  activeTabId?: string;
18
- onselect?: (tab: WorkspaceTab) => void;
19
- onclose?: (tabId: string) => void;
20
- oncloseothers?: (tabId: string) => void;
21
- oncloseall?: () => void;
22
- onrefresh?: (tab: WorkspaceTab) => void;
57
+ /** 可选上限,仅统计非固定标签;不提供则不设隐式默认值。 */
58
+ maxTabs?: number;
59
+ onselect?: ((tab: WorkspaceTab) => void) | undefined;
60
+ onclose?: ((tabId: string) => void) | undefined;
61
+ /**
62
+ * 关闭保护:返回 false(或抛错)时保留标签;异步确认期间不提前移除。
63
+ * 仅对可关闭且未固定的标签调用。
64
+ */
65
+ onbeforeclose?: ((tab: WorkspaceTab) => boolean | Promise<boolean>) | undefined;
66
+ oncloseothers?: ((tabId: string) => void) | undefined;
67
+ oncloseall?: (() => void) | undefined;
68
+ onrefresh?: ((tab: WorkspaceTab) => void) | undefined;
69
+ /** 打开准入失败且未改动 tabs 时报告,由宿主决定提示或拒绝。 */
70
+ onTabLimitReached?: ((detail: WorkspaceTabLimitReachedDetail) => void) | undefined;
23
71
  class?: string;
24
72
  }
25
73
 
26
74
  let {
27
75
  tabs = $bindable([]),
28
76
  activeTabId = $bindable(''),
77
+ maxTabs,
29
78
  onselect,
30
79
  onclose,
80
+ onbeforeclose,
31
81
  oncloseothers,
32
82
  oncloseall,
33
83
  onrefresh,
84
+ onTabLimitReached,
34
85
  class: className = '',
35
86
  }: Props = $props();
36
87
 
37
88
  function handleSelect(tab: WorkspaceTab) {
89
+ touchTab(tab);
38
90
  activeTabId = tab.id;
39
91
  onselect?.(tab);
40
92
  }
41
93
 
42
- function handleClose(e: MouseEvent, tabId: string) {
94
+ function handleRefresh(tab: WorkspaceTab) {
95
+ touchTab(tab);
96
+ onrefresh?.(tab);
97
+ }
98
+
99
+ function isRovingTab(tab: WorkspaceTab): boolean {
100
+ const activeIndex = tabs.findIndex((candidate) => candidate.id === activeTabId);
101
+ const rovingTab = tabs[activeIndex === -1 ? 0 : activeIndex];
102
+ return rovingTab?.id === tab.id;
103
+ }
104
+
105
+ type TabKeyboardEvent = KeyboardEvent & { currentTarget: EventTarget & HTMLDivElement };
106
+
107
+ function handleTabKeydown(event: TabKeyboardEvent, tab: WorkspaceTab) {
108
+ if (event.key === 'Enter' || event.key === ' ') {
109
+ event.preventDefault();
110
+ handleSelect(tab);
111
+ return;
112
+ }
113
+
114
+ const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];
115
+ if (!keys.includes(event.key)) return;
116
+
117
+ const tabList = event.currentTarget.closest('[role="tablist"]');
118
+ if (!tabList) return;
119
+ const tabElements = Array.from(tabList.querySelectorAll<HTMLElement>('[role="tab"]'));
120
+ const currentIndex = tabs.findIndex((candidate) => candidate.id === tab.id);
121
+ if (currentIndex === -1 || tabs.length === 0) return;
122
+
123
+ const nextIndex =
124
+ event.key === 'Home'
125
+ ? 0
126
+ : event.key === 'End'
127
+ ? tabs.length - 1
128
+ : event.key === 'ArrowRight'
129
+ ? (currentIndex + 1) % tabs.length
130
+ : (currentIndex - 1 + tabs.length) % tabs.length;
131
+ const nextTab = tabs[nextIndex];
132
+ if (!nextTab) return;
133
+
134
+ event.preventDefault();
135
+ tabElements[nextIndex]?.focus();
136
+ handleSelect(nextTab);
137
+ }
138
+
139
+ let disposed = false;
140
+ let closeOperationInFlight = false;
141
+ onDestroy(() => {
142
+ disposed = true;
143
+ });
144
+
145
+ function isClosable(tab: WorkspaceTab): boolean {
146
+ return !tab.pinned && tab.closable !== false;
147
+ }
148
+
149
+ async function confirmClose(tab: WorkspaceTab): Promise<boolean> {
150
+ if (!onbeforeclose) return true;
151
+ try {
152
+ return (await onbeforeclose(tab)) !== false;
153
+ } catch {
154
+ // 守卫不可用时按保护处理,避免丢失草稿。
155
+ return false;
156
+ }
157
+ }
158
+
159
+ async function confirmEviction(tab: WorkspaceTab): Promise<boolean> {
160
+ if (!onbeforeclose) return true;
161
+ try {
162
+ // 自动淘汰必须由守卫明确批准;truthy 的非 boolean 一律按拒绝处理。
163
+ return (await onbeforeclose(tab)) === true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ function findCurrent(tab: WorkspaceTab): { index: number; tab: WorkspaceTab } | undefined {
170
+ const index = tabs.findIndex((candidate) => candidate.id === tab.id);
171
+ const current = tabs[index];
172
+ return current === tab ? { index, tab: current } : undefined;
173
+ }
174
+
175
+ function tabAccessKey(tab: WorkspaceTab): string {
176
+ // 逻辑身份而非对象身份,宿主重建 tab 对象后仍保留访问顺序;path 隔离避免串路由。
177
+ return `${tab.id}\u0000${tab.path}`;
178
+ }
179
+
180
+ const touchOrder = new Map<string, number>();
181
+ let touchCounter = 0;
182
+ let lastObservedActiveTabId: string | undefined;
183
+
184
+ function pruneTouchOrder(list: WorkspaceTab[]): void {
185
+ const liveKeys = new Set(list.map(tabAccessKey));
186
+ for (const key of touchOrder.keys()) {
187
+ if (!liveKeys.has(key)) touchOrder.delete(key);
188
+ }
189
+ }
190
+
191
+ function touchTab(tab: WorkspaceTab) {
192
+ // 同一逻辑 id+path 只保留最后一次实际访问顺序;首次并列时由数组下标兜底。
193
+ touchCounter = Math.max(touchCounter, tabs.length) + 1;
194
+ touchOrder.set(tabAccessKey(tab), touchCounter);
195
+ }
196
+
197
+ $effect(() => {
198
+ pruneTouchOrder(tabs);
199
+ const observed = activeTabId;
200
+ if (observed === lastObservedActiveTabId) return;
201
+ lastObservedActiveTabId = observed;
202
+ const active = tabs.find((tab) => tab.id === observed);
203
+ if (active) touchTab(active);
204
+ });
205
+
206
+ function isValidMaxTabs(value: number | undefined): boolean {
207
+ return value === undefined || (Number.isSafeInteger(value) && value > 0);
208
+ }
209
+
210
+ function isAutoEvictable(tab: WorkspaceTab): boolean {
211
+ return (
212
+ !tab.pinned &&
213
+ tab.closable !== false &&
214
+ tab.dirty !== true &&
215
+ tab.id !== activeTabId
216
+ );
217
+ }
218
+
219
+ function quotaOf(list: WorkspaceTab[]): number {
220
+ return list.filter((tab) => !tab.pinned).length;
221
+ }
222
+
223
+ function lruCandidates(list: WorkspaceTab[], targetId: string): WorkspaceTab[] {
224
+ pruneTouchOrder(list);
225
+ const withIndex = list.map((tab, index) => ({ tab, index }));
226
+ return withIndex
227
+ .filter(({ tab }) => tab.id !== targetId && isAutoEvictable(tab))
228
+ .sort((a, b) => {
229
+ const rankA = touchOrder.get(tabAccessKey(a.tab)) ?? a.index;
230
+ const rankB = touchOrder.get(tabAccessKey(b.tab)) ?? b.index;
231
+ return rankA - rankB || a.index - b.index;
232
+ })
233
+ .map(({ tab }) => tab);
234
+ }
235
+
236
+ function activateFallback(removedIndex: number) {
237
+ if (disposed) return;
238
+ const nextTab = tabs[Math.min(removedIndex, tabs.length - 1)];
239
+ if (nextTab) {
240
+ activeTabId = nextTab.id;
241
+ onselect?.(nextTab);
242
+ return;
243
+ }
244
+ activeTabId = '';
245
+ }
246
+
247
+ function removeAllowed(allowed: WorkspaceTab[], activeIndex: number): boolean {
248
+ // 批次结束前不做赋值,最后一次性 immutable filter,避免自己重建数组导致引用失效。
249
+ const removable = allowed.filter((tab) => {
250
+ const current = findCurrent(tab);
251
+ return Boolean(current && isClosable(current.tab));
252
+ });
253
+ if (removable.length === 0) return false;
254
+
255
+ const removeSet = new Set(removable);
256
+ const activeRemoved = removable.some((tab) => tab.id === activeTabId);
257
+ tabs = tabs.filter((candidate) => !removeSet.has(candidate));
258
+ removable.forEach((tab) => onclose?.(tab.id));
259
+ if (activeRemoved) activateFallback(activeIndex);
260
+ return true;
261
+ }
262
+
263
+ async function handleClose(e: MouseEvent, tabId: string) {
43
264
  e.stopPropagation();
44
- const idx = tabs.findIndex((t) => t.id === tabId);
45
- if (idx === -1) return;
46
- const isCurrentActive = activeTabId === tabId;
47
- tabs = tabs.filter((t) => t.id !== tabId);
48
- onclose?.(tabId);
49
-
50
- if (isCurrentActive && tabs.length > 0) {
51
- const nextTab = tabs[Math.min(idx, tabs.length - 1)];
52
- if (nextTab) {
53
- activeTabId = nextTab.id;
54
- onselect?.(nextTab);
55
- }
265
+ if (closeOperationInFlight) return;
266
+ closeOperationInFlight = true;
267
+ try {
268
+ const tab = tabs.find((candidate) => candidate.id === tabId);
269
+ if (!tab || !isClosable(tab)) return;
270
+ if (!(await confirmClose(tab))) return;
271
+ if (disposed) return;
272
+
273
+ const current = findCurrent(tab);
274
+ if (!current || !isClosable(current.tab)) return;
275
+
276
+ tabs = tabs.filter((candidate) => candidate !== tab);
277
+ onclose?.(tab.id);
278
+ if (activeTabId === tab.id) activateFallback(current.index);
279
+ } finally {
280
+ closeOperationInFlight = false;
56
281
  }
57
282
  }
58
283
 
59
- function handleCloseAll() {
60
- const pinned = tabs.filter((t) => t.pinned);
61
- tabs = pinned;
62
- if (pinned.length > 0 && pinned[0]) {
63
- activeTabId = pinned[0].id;
64
- onselect?.(pinned[0]);
284
+ async function handleCloseAll() {
285
+ if (closeOperationInFlight) return;
286
+ closeOperationInFlight = true;
287
+ try {
288
+ const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId);
289
+ const allowed: WorkspaceTab[] = [];
290
+ // 顺序确认:上一个守卫结算后才询问下一个,避免同时弹出多个确认框。
291
+ for (const tab of tabs.slice()) {
292
+ if (disposed) return;
293
+ if (!isClosable(tab)) continue;
294
+ if (!(await confirmClose(tab))) continue;
295
+ if (disposed) return;
296
+
297
+ const current = findCurrent(tab);
298
+ if (current && isClosable(current.tab)) allowed.push(tab);
299
+ }
300
+ if (disposed) return;
301
+
302
+ removeAllowed(allowed, activeIndex);
303
+ oncloseall?.();
304
+ } finally {
305
+ closeOperationInFlight = false;
65
306
  }
66
- oncloseall?.();
67
307
  }
68
308
 
69
- function handleContextMenu(e: MouseEvent, tabId: string) {
309
+ async function handleContextMenu(e: MouseEvent, tabId: string) {
70
310
  e.preventDefault();
71
- if (oncloseothers) {
72
- tabs = tabs.filter((t) => t.pinned || t.id === tabId);
73
- activeTabId = tabId;
74
- oncloseothers(tabId);
311
+ if (!oncloseothers || closeOperationInFlight) return;
312
+ closeOperationInFlight = true;
313
+ try {
314
+ const target = tabs.find((tab) => tab.id === tabId);
315
+ if (!target) return;
316
+
317
+ const allowed: WorkspaceTab[] = [];
318
+ for (const tab of tabs.slice()) {
319
+ if (tab === target) continue;
320
+ if (disposed) return;
321
+ // 目标消失或被替换后立即中止,不再询问后续守卫。
322
+ if (!findCurrent(target)) return;
323
+ if (!isClosable(tab)) continue;
324
+ if (!(await confirmClose(tab))) continue;
325
+ if (disposed) return;
326
+ if (!findCurrent(target)) return;
327
+
328
+ const current = findCurrent(tab);
329
+ if (current && isClosable(current.tab)) allowed.push(tab);
330
+ }
331
+ if (disposed) return;
332
+ if (!findCurrent(target)) return;
333
+
334
+ const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId);
335
+ // 赋值前确认目标仍是原实例;赋值后只能按 id 判断目标仍在。
336
+ removeAllowed(allowed, activeIndex);
337
+
338
+ if (tabs.some((tab) => tab.id === target.id)) {
339
+ activeTabId = target.id;
340
+ oncloseothers(target.id);
341
+ }
342
+ } finally {
343
+ closeOperationInFlight = false;
344
+ }
345
+ }
346
+
347
+ interface OpenTabSnapshot {
348
+ sourceTabs: WorkspaceTab[];
349
+ maxTabs: number | undefined;
350
+ guard: Props['onbeforeclose'];
351
+ activeTabId: string;
352
+ entries: Array<{
353
+ tab: WorkspaceTab;
354
+ id: string;
355
+ path: string;
356
+ pinned: boolean | undefined;
357
+ dirty: boolean | undefined;
358
+ closable: boolean | undefined;
359
+ }>;
360
+ incoming: {
361
+ id: string;
362
+ path: string;
363
+ pinned: boolean | undefined;
364
+ dirty: boolean | undefined;
365
+ closable: boolean | undefined;
366
+ };
367
+ }
368
+
369
+ function captureOpenTabSnapshot(
370
+ sourceTabs: WorkspaceTab[],
371
+ observedMaxTabs: number | undefined,
372
+ incoming: WorkspaceTab,
373
+ ): OpenTabSnapshot {
374
+ return {
375
+ sourceTabs,
376
+ maxTabs: observedMaxTabs,
377
+ guard: onbeforeclose,
378
+ activeTabId,
379
+ entries: sourceTabs.map((tab) => ({
380
+ tab,
381
+ id: tab.id,
382
+ path: tab.path,
383
+ pinned: tab.pinned,
384
+ dirty: tab.dirty,
385
+ closable: tab.closable,
386
+ })),
387
+ incoming: {
388
+ id: incoming.id,
389
+ path: incoming.path,
390
+ pinned: incoming.pinned,
391
+ dirty: incoming.dirty,
392
+ closable: incoming.closable,
393
+ },
394
+ };
395
+ }
396
+
397
+ function isOpenSnapshotStale(snapshot: OpenTabSnapshot, incoming: WorkspaceTab): boolean {
398
+ if (
399
+ disposed ||
400
+ tabs !== snapshot.sourceTabs ||
401
+ maxTabs !== snapshot.maxTabs ||
402
+ onbeforeclose !== snapshot.guard ||
403
+ activeTabId !== snapshot.activeTabId
404
+ ) {
405
+ return true;
406
+ }
407
+ if (tabs.length !== snapshot.entries.length) return true;
408
+ for (let index = 0; index < snapshot.entries.length; index += 1) {
409
+ const entry = snapshot.entries[index];
410
+ if (!entry) return true;
411
+ const current = tabs[index];
412
+ if (
413
+ !current ||
414
+ current !== entry.tab ||
415
+ current.id !== entry.id ||
416
+ current.path !== entry.path ||
417
+ current.pinned !== entry.pinned ||
418
+ current.dirty !== entry.dirty ||
419
+ current.closable !== entry.closable
420
+ ) {
421
+ return true;
422
+ }
423
+ }
424
+ return (
425
+ incoming.id !== snapshot.incoming.id ||
426
+ incoming.path !== snapshot.incoming.path ||
427
+ incoming.pinned !== snapshot.incoming.pinned ||
428
+ incoming.dirty !== snapshot.incoming.dirty ||
429
+ incoming.closable !== snapshot.incoming.closable
430
+ );
431
+ }
432
+
433
+ /**
434
+ * 原子打开入口:maxTabs 只在这里准入,宿主直接改 tabs 或下调 maxTabs 不会触发删除。
435
+ * 淘汰需要 onbeforeclose 确认;任一守卫拒绝、抛错、卸载或状态变化都保持 tabs 不变。
436
+ */
437
+ export async function openTab(tab: WorkspaceTab): Promise<WorkspaceTabOpenResult> {
438
+ if (!isValidMaxTabs(maxTabs)) {
439
+ return { status: 'rejected', reason: 'invalid-max-tabs', tab };
440
+ }
441
+ if (disposed) {
442
+ return { status: 'rejected', reason: 'unmounted', tab };
443
+ }
444
+ if (closeOperationInFlight) {
445
+ return { status: 'rejected', reason: 'operation-in-flight', tab };
446
+ }
447
+
448
+ const existing = tabs.find((candidate) => candidate.id === tab.id);
449
+ if (existing) {
450
+ if (existing.path !== tab.path) {
451
+ return { status: 'rejected', reason: 'duplicate-id-path-mismatch', tab: existing };
452
+ }
453
+ touchTab(existing);
454
+ activeTabId = existing.id;
455
+ onselect?.(existing);
456
+ return { status: 'selected', tab: existing, evictedTabIds: [] };
457
+ }
458
+
459
+ closeOperationInFlight = true;
460
+ try {
461
+ const sourceTabs = tabs;
462
+ const observedMaxTabs = maxTabs;
463
+ const quota = quotaOf(sourceTabs);
464
+ const adjustedCount = quota + (tab.pinned ? 0 : 1);
465
+
466
+ // 新标签自身是固定标签时不占额,也不得因宿主已超额而连带淘汰普通标签。
467
+ if (tab.pinned === true || observedMaxTabs === undefined || adjustedCount <= observedMaxTabs) {
468
+ tabs = [...sourceTabs, tab];
469
+ touchTab(tab);
470
+ activeTabId = tab.id;
471
+ onselect?.(tab);
472
+ return { status: 'opened', tab, evictedTabIds: [] };
473
+ }
474
+
475
+ const needed = adjustedCount - observedMaxTabs;
476
+ const candidates = lruCandidates(sourceTabs, tab.id).slice(0, needed);
477
+ if (candidates.length < needed) {
478
+ onTabLimitReached?.({
479
+ tab,
480
+ maxTabs: observedMaxTabs,
481
+ quota,
482
+ reason: 'no-safe-candidate',
483
+ });
484
+ return { status: 'rejected', reason: 'limit-reached', tab };
485
+ }
486
+
487
+ const snapshot = captureOpenTabSnapshot(sourceTabs, observedMaxTabs, tab);
488
+ for (const candidate of candidates) {
489
+ if (isOpenSnapshotStale(snapshot, tab)) {
490
+ return { status: 'rejected', reason: disposed ? 'unmounted' : 'stale-state', tab };
491
+ }
492
+ if (!(await confirmEviction(candidate))) {
493
+ if (disposed) return { status: 'rejected', reason: 'unmounted', tab };
494
+ if (isOpenSnapshotStale(snapshot, tab)) {
495
+ return { status: 'rejected', reason: 'stale-state', tab };
496
+ }
497
+ onTabLimitReached?.({
498
+ tab,
499
+ maxTabs: observedMaxTabs,
500
+ quota,
501
+ reason: 'eviction-declined',
502
+ });
503
+ return { status: 'rejected', reason: 'eviction-declined', tab };
504
+ }
505
+ if (isOpenSnapshotStale(snapshot, tab)) {
506
+ return { status: 'rejected', reason: disposed ? 'unmounted' : 'stale-state', tab };
507
+ }
508
+ }
509
+
510
+ if (isOpenSnapshotStale(snapshot, tab)) {
511
+ return { status: 'rejected', reason: disposed ? 'unmounted' : 'stale-state', tab };
512
+ }
513
+
514
+ const removeSet = new Set(candidates);
515
+ tabs = [...sourceTabs.filter((candidate) => !removeSet.has(candidate)), tab];
516
+ touchTab(tab);
517
+ activeTabId = tab.id;
518
+ candidates.forEach((candidate) => onclose?.(candidate.id));
519
+ onselect?.(tab);
520
+ return {
521
+ status: 'opened',
522
+ tab,
523
+ evictedTabIds: candidates.map((candidate) => candidate.id),
524
+ };
525
+ } finally {
526
+ closeOperationInFlight = false;
75
527
  }
76
528
  }
77
529
  </script>
78
530
 
79
531
  <div class={cn('svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-8ef2268efbbc svadmin-u-65fdbade2025 svadmin-u-c9ed8c5f79ae svadmin-u-2859c861d7de svadmin-u-d5eab218aa34 svadmin-u-359090c2d529 svadmin-u-7f6912283f11', className)}>
80
532
  <!-- Tabs list -->
81
- <div role="tablist" aria-label="Workspace tabs" class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-44ee8ba0a421 svadmin-u-1384f66f41d0 svadmin-u-660d2effb880 scrollbar-none">
533
+ <div role="tablist" aria-label="Workspace tabs" aria-orientation="horizontal" class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-44ee8ba0a421 svadmin-u-1384f66f41d0 svadmin-u-660d2effb880 scrollbar-none">
82
534
  {#each tabs as tab (tab.id)}
83
535
  {@const isActive = tab.id === activeTabId}
84
536
  <div
85
- role="tab"
86
- tabindex="0"
87
- aria-selected={isActive}
537
+ role="presentation"
88
538
  class={cn(
89
539
  'group svadmin-u-d89972fe17d6 svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-58284b4ea568 svadmin-u-0e17f2bd9074 svadmin-u-ec0091ee009b svadmin-u-421ac2be5045 svadmin-u-359090c2d529 svadmin-u-2689f3958069 svadmin-u-0fe7d7d814d0 svadmin-u-34516836730d svadmin-u-ca6bcd4b6f3f',
90
540
  isActive
91
541
  ? 'svadmin-u-cd0ad9a56558 svadmin-u-d4108abe6359 svadmin-u-18049387f0af svadmin-u-cef5b893cf23'
92
542
  : 'svadmin-u-7f19cdf4c5bb svadmin-u-bfa603190748 svadmin-u-521fa0c7c407 svadmin-u-68646cdcc246 svadmin-u-ea7b2e9e070e'
93
543
  )}
94
- onclick={() => handleSelect(tab)}
95
- onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleSelect(tab); } }}
96
- oncontextmenu={(e) => handleContextMenu(e, tab.id)}
97
544
  >
98
- {#if tab.pinned}
99
- <Pin aria-hidden="true" class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29 svadmin-u-20aaf08a7ed1 svadmin-u-012fbd121f37 svadmin-u-c74901da6f6c" />
100
- {/if}
101
-
102
- <span class="svadmin-u-f283ea9bea0e svadmin-u-1d274d2422d8">{tab.title}</span>
545
+ <div
546
+ role="tab"
547
+ tabindex={isRovingTab(tab) ? 0 : -1}
548
+ aria-selected={isActive}
549
+ class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-58284b4ea568"
550
+ onclick={() => handleSelect(tab)}
551
+ onkeydown={(e) => handleTabKeydown(e, tab)}
552
+ oncontextmenu={(e) => handleContextMenu(e, tab.id)}
553
+ >
554
+ {#if tab.pinned}
555
+ <Pin aria-hidden="true" class="svadmin-u-6a60c09e6aaa svadmin-u-9cea05671a29 svadmin-u-20aaf08a7ed1 svadmin-u-012fbd121f37 svadmin-u-c74901da6f6c" />
556
+ {/if}
103
557
 
558
+ <span class="svadmin-u-f283ea9bea0e svadmin-u-1d274d2422d8">{tab.title}</span>
559
+ </div>
104
560
  {#if tab.closable !== false && !tab.pinned}
105
561
  <button
106
562
  type="button"
107
563
  class="svadmin-u-60fbb7713999 svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-3960ffc248d9 svadmin-u-86843cf1e227 svadmin-u-ac204c108886 svadmin-u-7be4d67a6256 svadmin-u-8db899b4e072 svadmin-u-51e95020d6f2 svadmin-u-ceb69a6b0e5f svadmin-u-8a539c7fe216 svadmin-u-119b2aa0b8f6 svadmin-u-34516836730d"
108
564
  onclick={(e) => handleClose(e, tab.id)}
565
+ oncontextmenu={(e) => handleContextMenu(e, tab.id)}
109
566
  aria-label={`Close ${tab.title}`}
110
567
  title={`Close ${tab.title}`}
111
568
  >
@@ -125,7 +582,7 @@
125
582
  variant="ghost"
126
583
  size="sm"
127
584
  class="svadmin-u-d0a52b312f7d svadmin-u-cbbf90f9a828 svadmin-u-8a539c7fe216 svadmin-u-bfa603190748 svadmin-u-ea7b2e9e070e"
128
- onclick={() => onrefresh(curTab)}
585
+ onclick={() => handleRefresh(curTab)}
129
586
  aria-label={`Refresh ${curTab.title}`}
130
587
  title={`Refresh ${curTab.title}`}
131
588
  >
@@ -140,6 +597,8 @@
140
597
  size="sm"
141
598
  class="svadmin-u-d0a52b312f7d svadmin-u-cbbf90f9a828 svadmin-u-8a539c7fe216 svadmin-u-bfa603190748 svadmin-u-51e95020d6f2"
142
599
  onclick={handleCloseAll}
600
+ aria-label="Close all tabs"
601
+ title="Close all tabs"
143
602
  >
144
603
  <XCircle aria-hidden="true" class="svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c" />
145
604
  </Button>
@@ -5,17 +5,51 @@ export interface WorkspaceTab {
5
5
  closable?: boolean;
6
6
  pinned?: boolean;
7
7
  icon?: string;
8
+ /** 消费方声明该标签存在未保存草稿,关闭保护由 onbeforeclose 决定。 */
9
+ dirty?: boolean;
10
+ }
11
+ export type WorkspaceTabOpenStatus = 'opened' | 'selected' | 'rejected';
12
+ export type WorkspaceTabOpenRejectReason = 'invalid-max-tabs' | 'duplicate-id-path-mismatch' | 'limit-reached' | 'eviction-declined' | 'operation-in-flight' | 'stale-state' | 'unmounted';
13
+ export interface WorkspaceTabOpenAcceptedResult {
14
+ status: 'opened' | 'selected';
15
+ tab: WorkspaceTab;
16
+ /** 本次成功打开时被确认淘汰的标签 id,按淘汰顺序排列。 */
17
+ evictedTabIds: string[];
18
+ }
19
+ export interface WorkspaceTabOpenRejectedResult {
20
+ status: 'rejected';
21
+ reason: WorkspaceTabOpenRejectReason;
22
+ tab: WorkspaceTab;
23
+ }
24
+ export type WorkspaceTabOpenResult = WorkspaceTabOpenAcceptedResult | WorkspaceTabOpenRejectedResult;
25
+ export interface WorkspaceTabLimitReachedDetail {
26
+ tab: WorkspaceTab;
27
+ maxTabs: number;
28
+ /** 当前非固定标签数量,固定标签不计入额度。 */
29
+ quota: number;
30
+ reason: 'no-safe-candidate' | 'eviction-declined';
8
31
  }
9
32
  interface Props {
10
33
  tabs?: WorkspaceTab[];
11
34
  activeTabId?: string;
12
- onselect?: (tab: WorkspaceTab) => void;
13
- onclose?: (tabId: string) => void;
14
- oncloseothers?: (tabId: string) => void;
15
- oncloseall?: () => void;
16
- onrefresh?: (tab: WorkspaceTab) => void;
35
+ /** 可选上限,仅统计非固定标签;不提供则不设隐式默认值。 */
36
+ maxTabs?: number;
37
+ onselect?: ((tab: WorkspaceTab) => void) | undefined;
38
+ onclose?: ((tabId: string) => void) | undefined;
39
+ /**
40
+ * 关闭保护:返回 false(或抛错)时保留标签;异步确认期间不提前移除。
41
+ * 仅对可关闭且未固定的标签调用。
42
+ */
43
+ onbeforeclose?: ((tab: WorkspaceTab) => boolean | Promise<boolean>) | undefined;
44
+ oncloseothers?: ((tabId: string) => void) | undefined;
45
+ oncloseall?: (() => void) | undefined;
46
+ onrefresh?: ((tab: WorkspaceTab) => void) | undefined;
47
+ /** 打开准入失败且未改动 tabs 时报告,由宿主决定提示或拒绝。 */
48
+ onTabLimitReached?: ((detail: WorkspaceTabLimitReachedDetail) => void) | undefined;
17
49
  class?: string;
18
50
  }
19
- declare const MultiTabKeepAlive: import("svelte").Component<Props, {}, "tabs" | "activeTabId">;
51
+ declare const MultiTabKeepAlive: import("svelte").Component<Props, {
52
+ openTab: (tab: WorkspaceTab) => Promise<WorkspaceTabOpenResult>;
53
+ }, "tabs" | "activeTabId">;
20
54
  type MultiTabKeepAlive = ReturnType<typeof MultiTabKeepAlive>;
21
55
  export default MultiTabKeepAlive;