@svadmin/ui 0.88.1 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.css +4 -0
- package/dist/app.theme.css +4 -0
- package/dist/component-registry.svelte.d.ts +2 -2
- package/dist/components/AboutSettings.svelte +23 -4
- package/dist/components/AdminApp.svelte +6 -4
- package/dist/components/AdminApp.svelte.d.ts +3 -3
- package/dist/components/ApiSettings.svelte +43 -27
- package/dist/components/ApprovalActionCard.svelte +8 -9
- package/dist/components/ApprovalCenter.svelte +24 -5
- package/dist/components/AuditLogViewer.svelte +2 -2
- package/dist/components/AutoComplete.svelte +610 -0
- package/dist/components/AutoComplete.svelte.d.ts +25 -0
- package/dist/components/AutoForm.svelte +265 -18
- package/dist/components/AutoForm.svelte.d.ts +19 -2
- package/dist/components/AutoTable.svelte +141 -36
- package/dist/components/AutoTable.svelte.d.ts +8 -0
- package/dist/components/BatchActionBar.svelte +42 -14
- package/dist/components/BatchActionBar.svelte.d.ts +5 -0
- package/dist/components/BoundRecordDetailDrawer.svelte +5 -3
- package/dist/components/CancelTaskButton.svelte +14 -1
- package/dist/components/Cascader.svelte +412 -53
- package/dist/components/Cascader.svelte.d.ts +7 -1
- package/dist/components/ColorPicker.svelte +1 -1
- package/dist/components/ColorPicker.svelte.d.ts +1 -1
- package/dist/components/ComboboxField.svelte +270 -34
- package/dist/components/ComboboxField.svelte.d.ts +4 -0
- package/dist/components/CommandPalette.svelte +4 -4
- package/dist/components/DashboardView.svelte +73 -7
- package/dist/components/DashboardView.svelte.d.ts +3 -1
- package/dist/components/DateRangeInput.svelte +8 -1
- package/dist/components/DetailWorkspaceAsyncSection.svelte +107 -0
- package/dist/components/DetailWorkspaceAsyncSection.svelte.d.ts +36 -0
- package/dist/components/DetailWorkspaceSections.svelte +233 -0
- package/dist/components/DetailWorkspaceSections.svelte.d.ts +9 -0
- package/dist/components/DrawerForm.svelte +78 -9
- package/dist/components/DrawerForm.svelte.d.ts +3 -3
- package/dist/components/ErrorBoundary.svelte +1 -1
- package/dist/components/ErrorComponent.svelte +19 -6
- package/dist/components/ErrorComponent.svelte.d.ts +1 -0
- package/dist/components/ErrorSummary.svelte +9 -4
- package/dist/components/FieldRenderer.svelte +203 -90
- package/dist/components/FieldRenderer.svelte.d.ts +16 -1
- package/dist/components/FileUpload.svelte +116 -51
- package/dist/components/FileUpload.svelte.d.ts +7 -27
- package/dist/components/FileUploadField.svelte +170 -0
- package/dist/components/FileUploadField.svelte.d.ts +21 -0
- package/dist/components/FilterBuilder.svelte +13 -7
- package/dist/components/FilterCollectionInput.svelte +9 -7
- package/dist/components/FilterCollectionInput.svelte.d.ts +3 -0
- package/dist/components/ForgotPasswordPage.svelte +1 -1
- package/dist/components/Header.svelte +1 -1
- package/dist/components/ImportWizard.svelte +197 -28
- package/dist/components/ImportWizard.svelte.d.ts +5 -1
- package/dist/components/InferencerPanel.svelte +4 -2
- package/dist/components/InfiniteList.svelte +52 -9
- package/dist/components/JsonSchemaForm.svelte +237 -42
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -3
- package/dist/components/KanbanBoard.svelte +43 -7
- package/dist/components/KanbanBoard.svelte.d.ts +2 -2
- package/dist/components/Layout.svelte +1 -1
- package/dist/components/LoginPage.svelte +1 -1
- package/dist/components/MetricStrip.svelte +8 -3
- package/dist/components/MetricStrip.svelte.d.ts +1 -0
- package/dist/components/ModalForm.svelte +85 -9
- package/dist/components/ModalForm.svelte.d.ts +3 -3
- package/dist/components/MoneyInput.svelte +5 -3
- package/dist/components/MoneyInput.svelte.d.ts +4 -2
- package/dist/components/MultiTabKeepAlive.svelte +502 -43
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +40 -6
- package/dist/components/NotificationsSettings.svelte +10 -10
- package/dist/components/NumberInput.svelte +5 -3
- package/dist/components/NumberInput.svelte.d.ts +4 -2
- package/dist/components/NumericInput.svelte +277 -25
- package/dist/components/NumericInput.svelte.d.ts +3 -1
- package/dist/components/PercentInput.svelte +5 -3
- package/dist/components/PercentInput.svelte.d.ts +4 -2
- package/dist/components/PermissionMatrix.svelte +44 -8
- package/dist/components/PermissionMatrix.svelte.d.ts +1 -0
- package/dist/components/ProfilePage.svelte +38 -13
- package/dist/components/RadioGroup.svelte +203 -4
- package/dist/components/RadioGroup.svelte.d.ts +1 -1
- package/dist/components/Rate.svelte +3 -3
- package/dist/components/Rate.svelte.d.ts +2 -2
- package/dist/components/RecordDetailDrawer.svelte +1 -1
- package/dist/components/RecordDetailDrawer.svelte.d.ts +1 -1
- package/dist/components/RegisterPage.svelte +1 -1
- package/dist/components/RelationPicker.svelte +39 -11
- package/dist/components/ResourceAccessGuard.svelte +7 -2
- package/dist/components/RetryTaskButton.svelte +14 -1
- package/dist/components/RolesSettings.svelte +74 -28
- package/dist/components/SecuritySettings.svelte +66 -51
- package/dist/components/SegmentedControl.svelte +4 -2
- package/dist/components/SegmentedControl.svelte.d.ts +1 -1
- package/dist/components/Sidebar.svelte +1 -1
- package/dist/components/StepsForm.svelte +62 -10
- package/dist/components/StepsForm.svelte.d.ts +4 -2
- package/dist/components/SvadminLogo.svelte +1 -0
- package/dist/components/SvarDataGrid.svelte +42 -24
- package/dist/components/SvarDataGrid.svelte.d.ts +10 -10
- package/dist/components/SvarResourceRead.svelte +2 -0
- package/dist/components/SvarResourceTableView.svelte +4 -2
- package/dist/components/TagsInput.svelte +3 -3
- package/dist/components/TagsInput.svelte.d.ts +2 -2
- package/dist/components/TaskDetails.svelte +22 -16
- package/dist/components/TaskDetails.svelte.d.ts +1 -1
- package/dist/components/TaskList.svelte +57 -15
- package/dist/components/TaskQueueDrawer.svelte +54 -34
- package/dist/components/Toast.svelte +13 -2
- package/dist/components/Transfer.svelte +67 -5
- package/dist/components/Transfer.svelte.d.ts +4 -0
- package/dist/components/TreeSelect.svelte +337 -77
- package/dist/components/TreeSelect.svelte.d.ts +4 -0
- package/dist/components/TreeTable.svelte +6 -1
- package/dist/components/TwoFactorAuthPage.svelte +8 -4
- package/dist/components/UpdatePasswordPage.svelte +1 -1
- package/dist/components/VersionDiffViewer.svelte +9 -7
- package/dist/components/account/CompanyProfilePage.svelte +13 -12
- package/dist/components/account/MemberDirectory.svelte +14 -10
- package/dist/components/account/SettingsEnterprisePage.svelte +18 -18
- package/dist/components/content/AuditTimeline.svelte +8 -6
- package/dist/components/content/DataState.svelte +1 -1
- package/dist/components/content/FileList.svelte +44 -13
- package/dist/components/content/FileList.svelte.d.ts +4 -2
- package/dist/components/content/FileListRow.svelte +87 -0
- package/dist/components/content/FileListRow.svelte.d.ts +11 -0
- package/dist/components/content/ImportDropzone.svelte +109 -12
- package/dist/components/content/ImportDropzone.svelte.d.ts +1 -0
- package/dist/components/content/IntegrationCard.svelte +3 -3
- package/dist/components/content/IntegrationCard.svelte.d.ts +2 -0
- package/dist/components/content/MediaThumbnail.svelte +6 -2
- package/dist/components/detail-workspace.d.ts +60 -0
- package/dist/components/detail-workspace.js +3 -0
- package/dist/components/enterprise/json-schema-form.d.ts +1 -1
- package/dist/components/enterprise/recursive-schema-form.d.ts +15 -1
- package/dist/components/enterprise/recursive-schema-form.js +288 -14
- package/dist/components/enterprise/schema-form-references.d.ts +14 -0
- package/dist/components/enterprise/schema-form-references.js +316 -0
- package/dist/components/fields/ArrayField.svelte +178 -14
- package/dist/components/fields/ArrayField.svelte.d.ts +17 -3
- package/dist/components/fields/array-field-metadata.d.ts +4 -0
- package/dist/components/fields/array-field-metadata.js +36 -0
- package/dist/components/file-upload-reference.d.ts +11 -0
- package/dist/components/file-upload-reference.js +50 -0
- package/dist/components/file-upload-types.d.ts +32 -0
- package/dist/components/file-upload-types.js +1 -0
- package/dist/components/form-contract.upload-type-fixture.svelte +108 -0
- package/dist/components/form-contract.upload-type-fixture.svelte.d.ts +18 -0
- package/dist/components/json-schema-form-state.d.ts +19 -0
- package/dist/components/list-deletion.svelte.js +6 -1
- package/dist/components/list-state.svelte.d.ts +12 -1
- package/dist/components/list-state.svelte.js +56 -16
- package/dist/components/numeric-format.d.ts +44 -0
- package/dist/components/numeric-format.js +198 -0
- package/dist/components/record-detail.svelte.js +2 -0
- package/dist/components/saved-list-views.d.ts +9 -2
- package/dist/components/saved-list-views.js +42 -11
- package/dist/components/saved-list-views.svelte.d.ts +3 -0
- package/dist/components/saved-list-views.svelte.js +56 -7
- package/dist/components/task-action.svelte.d.ts +1 -0
- package/dist/components/task-action.svelte.js +55 -3
- package/dist/components/task-utils.d.ts +1 -0
- package/dist/components/task-utils.js +18 -0
- package/dist/components/ui/input/input.svelte +1 -1
- package/dist/components/ui/input/input.svelte.d.ts +1 -1
- package/dist/components/ui/sheet/sheet.svelte +4 -1
- package/dist/components/version-history.d.ts +14 -0
- package/dist/components/version-history.js +32 -0
- package/dist/components/workspace-tab-state.d.ts +78 -0
- package/dist/components/workspace-tab-state.js +587 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +4 -0
- package/package.json +5 -5
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
5
5
|
import { captureAdminContext, captureAuthSession, useResourceContract, useSelect } from '@svadmin/core';
|
|
6
6
|
import { onDestroy, untrack } from 'svelte';
|
|
7
|
+
import { on } from 'svelte/events';
|
|
7
8
|
import type { BaseRecord, Filter } from '@svadmin/core';
|
|
8
9
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
9
10
|
import type { ButtonProps } from './ui/button/index.js';
|
|
@@ -14,12 +15,16 @@
|
|
|
14
15
|
|
|
15
16
|
const i18n = useTranslation();
|
|
16
17
|
|
|
17
|
-
function
|
|
18
|
+
function menuInteractions(node: HTMLElement) {
|
|
18
19
|
const handler = (e: MouseEvent) => {
|
|
19
|
-
if (e.target instanceof Node && !node.contains(e.target)
|
|
20
|
+
if (e.target instanceof Node && !node.contains(e.target) && open) closeMenu();
|
|
20
21
|
};
|
|
21
22
|
document.addEventListener('click', handler, true);
|
|
22
|
-
|
|
23
|
+
const removeKeydown = on(node, 'keydown', handleMenuKeydown);
|
|
24
|
+
return { destroy() {
|
|
25
|
+
document.removeEventListener('click', handler, true);
|
|
26
|
+
removeKeydown();
|
|
27
|
+
} };
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
interface Props extends Omit<HTMLButtonAttributes, 'value' | 'onchange' | 'children' | 'onclick' | 'type'> {
|
|
@@ -33,6 +38,10 @@
|
|
|
33
38
|
multiple?: boolean;
|
|
34
39
|
onSearch?: (value: string) => Filter[];
|
|
35
40
|
fetchSize?: number;
|
|
41
|
+
requestTimeout?: number;
|
|
42
|
+
virtualized?: boolean;
|
|
43
|
+
itemHeight?: number;
|
|
44
|
+
viewportHeight?: number;
|
|
36
45
|
id?: string;
|
|
37
46
|
class?: string;
|
|
38
47
|
}
|
|
@@ -48,6 +57,10 @@
|
|
|
48
57
|
multiple = false,
|
|
49
58
|
onSearch,
|
|
50
59
|
fetchSize = 50,
|
|
60
|
+
requestTimeout,
|
|
61
|
+
virtualized = true,
|
|
62
|
+
itemHeight = 36,
|
|
63
|
+
viewportHeight = 280,
|
|
51
64
|
disabled = false,
|
|
52
65
|
...restProps
|
|
53
66
|
}: Props = $props();
|
|
@@ -57,23 +70,43 @@
|
|
|
57
70
|
let searchInputValue = $state('');
|
|
58
71
|
let page = $state(1);
|
|
59
72
|
let appliedSearch = $state('');
|
|
73
|
+
let triggerButton = $state<HTMLButtonElement | HTMLAnchorElement | null>(null);
|
|
74
|
+
let searchInput = $state<HTMLInputElement | null>(null);
|
|
75
|
+
let commandRoot = $state<HTMLElement | null>(null);
|
|
76
|
+
let listElement = $state<HTMLElement | null>(null);
|
|
77
|
+
let listScrollTop = $state(0);
|
|
78
|
+
let activeIndex = $state(-1);
|
|
79
|
+
let loadMoreButton = $state<HTMLButtonElement | HTMLAnchorElement | null>(null);
|
|
60
80
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
61
81
|
onDestroy(() => clearTimeout(timer));
|
|
62
82
|
const normalizedFetchSize = $derived(
|
|
63
83
|
Number.isSafeInteger(fetchSize) && fetchSize >= 10 && fetchSize <= 200 ? fetchSize : 50,
|
|
64
84
|
);
|
|
85
|
+
const normalizedItemHeight = $derived(
|
|
86
|
+
Number.isSafeInteger(itemHeight) && itemHeight >= 32 && itemHeight <= 100 ? itemHeight : 36,
|
|
87
|
+
);
|
|
88
|
+
const normalizedViewportHeight = $derived(
|
|
89
|
+
Number.isSafeInteger(viewportHeight) && viewportHeight >= 120 && viewportHeight <= 800 ? viewportHeight : 280,
|
|
90
|
+
);
|
|
65
91
|
|
|
66
92
|
const binding = useResourceContract(() => resource);
|
|
67
93
|
const context = captureAdminContext();
|
|
94
|
+
const instanceId = $props.id();
|
|
95
|
+
const listboxId = $derived(`${instanceId}-listbox`);
|
|
96
|
+
const optionId = (index: number) => `${instanceId}-option-${index}`;
|
|
68
97
|
const sourceScope = $derived({
|
|
69
98
|
contract: binding.resource, provider: context.providers?.[binding.dataProviderName],
|
|
70
99
|
meta: JSON.stringify(binding.meta), tenant: context.tenantCacheKey?.__svadminTenant,
|
|
71
100
|
auth: context.authProvider, session: captureAuthSession(context.authProvider).cacheKey,
|
|
72
101
|
access: context.accessControlProvider, router: context.routerProvider,
|
|
73
|
-
optionLabel, optionValue, onSearch, searchable, normalizedFetchSize,
|
|
102
|
+
optionLabel, optionValue, onSearch, searchable, normalizedFetchSize, requestTimeout: String(requestTimeout),
|
|
74
103
|
});
|
|
104
|
+
function sameSourceScope(left: typeof sourceScope, right: typeof sourceScope): boolean {
|
|
105
|
+
return (Object.keys(left) as (keyof typeof sourceScope)[]).every(key => left[key] === right[key]);
|
|
106
|
+
}
|
|
75
107
|
type Option = { value: string | number; label: string };
|
|
76
108
|
let loaded = $state.raw<{ scope: typeof sourceScope; search: string; options: Option[] }>();
|
|
109
|
+
let observedSourceScope = untrack(() => sourceScope);
|
|
77
110
|
const defaultSearch = $derived.by(() => {
|
|
78
111
|
const field = optionLabel;
|
|
79
112
|
return (v: string): Filter[] => v ? [{ field, operator: 'contains', value: v }] : [];
|
|
@@ -102,6 +135,7 @@
|
|
|
102
135
|
},
|
|
103
136
|
get projectionKey() { return `combobox:${optionLabel}:${optionValue}`; },
|
|
104
137
|
get fetchSize() { return normalizedFetchSize; },
|
|
138
|
+
get requestTimeout() { return requestTimeout; },
|
|
105
139
|
get pagination() { return { current: page, pageSize: normalizedFetchSize }; },
|
|
106
140
|
get defaultValue() {
|
|
107
141
|
if (Array.isArray(value)) return value;
|
|
@@ -110,14 +144,21 @@
|
|
|
110
144
|
get filters() { return searchFilters.filters; },
|
|
111
145
|
get queryOptions() { return { enabled: !searchFilters.invalid }; },
|
|
112
146
|
}));
|
|
147
|
+
onDestroy(() => select.cancel());
|
|
113
148
|
|
|
114
149
|
$effect.pre(() => {
|
|
115
|
-
|
|
150
|
+
const nextScope = sourceScope;
|
|
151
|
+
const previousScope = untrack(() => observedSourceScope);
|
|
152
|
+
// 字段说明和错误更新不改变查询作用域,也不应打断当前操作。
|
|
153
|
+
if (sameSourceScope(previousScope, nextScope)) return;
|
|
154
|
+
observedSourceScope = nextScope;
|
|
116
155
|
clearTimeout(timer);
|
|
117
156
|
searchInputValue = appliedSearch = '';
|
|
118
157
|
open = false;
|
|
119
158
|
page = 1;
|
|
120
159
|
loaded = undefined;
|
|
160
|
+
listScrollTop = 0;
|
|
161
|
+
select.cancel();
|
|
121
162
|
});
|
|
122
163
|
$effect(() => {
|
|
123
164
|
const data = select.query.data;
|
|
@@ -126,7 +167,7 @@
|
|
|
126
167
|
const search = appliedSearch;
|
|
127
168
|
const previous = untrack(() => loaded);
|
|
128
169
|
const options = new Map<string | number, Option>(
|
|
129
|
-
page > 1 && previous
|
|
170
|
+
page > 1 && previous && sameSourceScope(previous.scope, scope) && previous.search === search
|
|
130
171
|
? previous.options.map(option => [option.value, option]) : [],
|
|
131
172
|
);
|
|
132
173
|
for (const option of data.options) options.set(option.value, option);
|
|
@@ -139,22 +180,86 @@
|
|
|
139
180
|
const availableOptions = $derived.by(() => {
|
|
140
181
|
if (select.isError || searchFilters.invalid) return [];
|
|
141
182
|
const options = new Map<string | number, Option>(
|
|
142
|
-
loaded
|
|
183
|
+
loaded && sameSourceScope(loaded.scope, sourceScope) && loaded.search === appliedSearch
|
|
143
184
|
? loaded.options.map(option => [option.value, option]) : [],
|
|
144
185
|
);
|
|
145
186
|
for (const option of select.options) options.set(option.value, option);
|
|
146
187
|
return [...options.values()];
|
|
147
188
|
});
|
|
148
189
|
const totalOptions = $derived(select.query.data?.total ?? availableOptions.length);
|
|
149
|
-
const
|
|
190
|
+
const shouldVirtualize = $derived(virtualized && availableOptions.length >= 200);
|
|
191
|
+
const virtualTotalHeight = $derived(availableOptions.length * normalizedItemHeight);
|
|
192
|
+
const virtualMaxScroll = $derived(Math.max(0, virtualTotalHeight - normalizedViewportHeight));
|
|
193
|
+
const clampedListScrollTop = $derived(Math.max(0, Math.min(listScrollTop, virtualMaxScroll)));
|
|
194
|
+
const virtualStart = $derived(Math.max(0, Math.floor(clampedListScrollTop / normalizedItemHeight) - 5));
|
|
195
|
+
const virtualEnd = $derived(Math.min(availableOptions.length,
|
|
196
|
+
Math.ceil((clampedListScrollTop + normalizedViewportHeight) / normalizedItemHeight) + 5));
|
|
197
|
+
const renderedOptions = $derived(shouldVirtualize
|
|
198
|
+
? availableOptions.slice(virtualStart, virtualEnd) : availableOptions);
|
|
199
|
+
const activeOptionId = $derived(shouldVirtualize && activeIndex >= 0 && availableOptions[activeIndex]
|
|
200
|
+
&& activeIndex >= virtualStart && activeIndex < virtualEnd ? optionId(activeIndex) : undefined);
|
|
201
|
+
const hasMore = $derived((totalOptions > page * normalizedFetchSize || (select.query.isFetching && page > 1)) && !select.isError
|
|
150
202
|
&& !searchFilters.invalid && searchInputValue === appliedSearch);
|
|
151
203
|
const invalidOptions = $derived(select.isError || searchFilters.invalid);
|
|
204
|
+
// 已确认值的回显独立于候选列表的错误/搜索过滤,避免后续分页失败或搜索期间清空选中摘要。
|
|
205
|
+
const selectionOptions = $derived.by(() => {
|
|
206
|
+
const options = new Map<string | number, Option>();
|
|
207
|
+
// 已选记录查询失败时沿用旧实现的隐藏标签边界,不能用旧页缓存回显。
|
|
208
|
+
if (select.defaultValueQuery.isError) return options;
|
|
209
|
+
if (loaded && sameSourceScope(loaded.scope, sourceScope)) {
|
|
210
|
+
for (const option of loaded.options) options.set(option.value, option);
|
|
211
|
+
}
|
|
212
|
+
for (const option of select.options) options.set(option.value, option);
|
|
213
|
+
return options;
|
|
214
|
+
});
|
|
152
215
|
const selectedLabels = $derived(selectedValues.flatMap(selectedValue => {
|
|
153
|
-
const option =
|
|
216
|
+
const option = selectionOptions.get(selectedValue);
|
|
154
217
|
return option ? [option.label] : [];
|
|
155
218
|
}));
|
|
156
219
|
const selectedLabel = $derived(selectedLabels.join(', '));
|
|
157
220
|
|
|
221
|
+
$effect.pre(() => {
|
|
222
|
+
if (disabled) closeMenu();
|
|
223
|
+
});
|
|
224
|
+
$effect(() => {
|
|
225
|
+
if (open && !disabled) (searchable ? searchInput : commandRoot)?.focus();
|
|
226
|
+
});
|
|
227
|
+
$effect.pre(() => {
|
|
228
|
+
if (!hasMore && open && loadMoreButton && document.activeElement === loadMoreButton) {
|
|
229
|
+
(searchable ? searchInput : commandRoot)?.focus();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
$effect(() => {
|
|
233
|
+
const count = availableOptions.length;
|
|
234
|
+
if (activeIndex >= count) activeIndex = count > 0 ? count - 1 : -1;
|
|
235
|
+
});
|
|
236
|
+
$effect(() => {
|
|
237
|
+
if (!shouldVirtualize || activeIndex < 0) return;
|
|
238
|
+
if (activeIndex < virtualStart || activeIndex >= virtualEnd) activeIndex = -1;
|
|
239
|
+
});
|
|
240
|
+
$effect(() => {
|
|
241
|
+
if (listScrollTop !== clampedListScrollTop) {
|
|
242
|
+
listScrollTop = clampedListScrollTop;
|
|
243
|
+
if (listElement) listElement.scrollTop = clampedListScrollTop;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
function closeMenu(restoreFocus = false): void {
|
|
248
|
+
open = false;
|
|
249
|
+
activeIndex = -1;
|
|
250
|
+
resetSearch();
|
|
251
|
+
if (restoreFocus && !disabled) triggerButton?.focus();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function retainMultiSelectFocus(): void {
|
|
255
|
+
if (!open || disabled) return;
|
|
256
|
+
const target = searchable ? searchInput : commandRoot;
|
|
257
|
+
const active = document.activeElement;
|
|
258
|
+
if (!target || (active !== document.body && active !== document.documentElement
|
|
259
|
+
&& !commandRoot?.contains(active))) return;
|
|
260
|
+
target.focus();
|
|
261
|
+
}
|
|
262
|
+
|
|
158
263
|
function handleSelect(optValue: string | number) {
|
|
159
264
|
if (disabled || invalidOptions || select.isFetching || searchInputValue !== appliedSearch) return;
|
|
160
265
|
if (multiple) {
|
|
@@ -162,26 +267,31 @@
|
|
|
162
267
|
? selectedValues.filter(selectedValue => selectedValue !== optValue)
|
|
163
268
|
: [...selectedValues, optValue];
|
|
164
269
|
onchange?.(next);
|
|
270
|
+
retainMultiSelectFocus();
|
|
165
271
|
return;
|
|
166
272
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
273
|
+
const next = optValue === value ? null : optValue;
|
|
274
|
+
closeMenu(true);
|
|
275
|
+
onchange?.(next);
|
|
170
276
|
}
|
|
171
277
|
|
|
172
278
|
function handleClear(e: Event) {
|
|
173
279
|
e.stopPropagation();
|
|
174
280
|
if (disabled) return;
|
|
281
|
+
closeMenu(true);
|
|
175
282
|
onchange?.(multiple ? [] : null);
|
|
176
|
-
open = false;
|
|
177
|
-
resetSearch();
|
|
178
283
|
}
|
|
179
284
|
|
|
180
285
|
function resetSearch(): void {
|
|
181
286
|
clearTimeout(timer);
|
|
287
|
+
timer = undefined;
|
|
182
288
|
searchInputValue = appliedSearch = '';
|
|
183
289
|
page = 1;
|
|
184
290
|
loaded = undefined;
|
|
291
|
+
listScrollTop = 0;
|
|
292
|
+
activeIndex = -1;
|
|
293
|
+
if (listElement) listElement.scrollTop = 0;
|
|
294
|
+
select.cancel();
|
|
185
295
|
}
|
|
186
296
|
|
|
187
297
|
function handleSearchInput(e: Event) {
|
|
@@ -191,10 +301,13 @@
|
|
|
191
301
|
clearTimeout(timer);
|
|
192
302
|
const scope = sourceScope;
|
|
193
303
|
const apply = () => {
|
|
194
|
-
if (
|
|
304
|
+
if (!open || searchInputValue !== v || !sameSourceScope(scope, sourceScope) || disabled) return;
|
|
195
305
|
appliedSearch = v;
|
|
196
306
|
page = 1;
|
|
197
307
|
loaded = undefined;
|
|
308
|
+
listScrollTop = 0;
|
|
309
|
+
activeIndex = -1;
|
|
310
|
+
if (listElement) listElement.scrollTop = 0;
|
|
198
311
|
};
|
|
199
312
|
if (!v) apply();
|
|
200
313
|
else timer = setTimeout(apply, 300);
|
|
@@ -204,19 +317,123 @@
|
|
|
204
317
|
if (disabled || select.isFetching || !hasMore) return;
|
|
205
318
|
page += 1;
|
|
206
319
|
}
|
|
320
|
+
|
|
321
|
+
function handleTriggerKeydown(event: KeyboardEvent): void {
|
|
322
|
+
if (event.defaultPrevented || disabled || event.isComposing
|
|
323
|
+
|| event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
324
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
if (open) (searchable ? searchInput : commandRoot)?.focus();
|
|
327
|
+
else open = true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isButtonKeydown(
|
|
332
|
+
event: KeyboardEvent,
|
|
333
|
+
): event is KeyboardEvent & { currentTarget: HTMLButtonElement } {
|
|
334
|
+
return event.currentTarget instanceof HTMLButtonElement;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handleTriggerKeydownWithHost(event: KeyboardEvent): void {
|
|
338
|
+
if (isButtonKeydown(event)) restProps.onkeydown?.(event);
|
|
339
|
+
handleTriggerKeydown(event);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function handleMenuKeydown(event: KeyboardEvent): void {
|
|
343
|
+
if (!open || event.key !== 'Escape' || event.defaultPrevented || event.isComposing) return;
|
|
344
|
+
event.preventDefault();
|
|
345
|
+
event.stopPropagation();
|
|
346
|
+
closeMenu(true);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function revealActive(): void {
|
|
350
|
+
if (!shouldVirtualize || activeIndex < 0) return;
|
|
351
|
+
const top = activeIndex * normalizedItemHeight;
|
|
352
|
+
const bottom = top + normalizedItemHeight;
|
|
353
|
+
if (top < clampedListScrollTop) listScrollTop = top;
|
|
354
|
+
else if (bottom > clampedListScrollTop + normalizedViewportHeight) {
|
|
355
|
+
listScrollTop = bottom - normalizedViewportHeight;
|
|
356
|
+
}
|
|
357
|
+
if (listElement) listElement.scrollTop = listScrollTop;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function moveActive(delta: number): void {
|
|
361
|
+
const count = availableOptions.length;
|
|
362
|
+
if (!count) {
|
|
363
|
+
activeIndex = -1;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const start = activeIndex < 0 ? (delta > 0 ? -1 : count) : activeIndex;
|
|
367
|
+
activeIndex = (start + delta + count) % count;
|
|
368
|
+
revealActive();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 窗口化只挂载少量 Command.Item,键盘导航必须基于完整候选而不是已挂载节点。
|
|
372
|
+
function handleVirtualNavigation(event: KeyboardEvent): void {
|
|
373
|
+
if (!open || disabled || event.defaultPrevented || event.isComposing
|
|
374
|
+
|| event.altKey || event.ctrlKey || event.metaKey || !shouldVirtualize) return;
|
|
375
|
+
const target = event.target;
|
|
376
|
+
if (!(target instanceof Node) || (target !== searchInput
|
|
377
|
+
&& !(commandRoot && commandRoot.contains(target)))) return;
|
|
378
|
+
if (!availableOptions.length) return;
|
|
379
|
+
const key = event.key;
|
|
380
|
+
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Home' || key === 'End') {
|
|
381
|
+
event.preventDefault();
|
|
382
|
+
event.stopPropagation();
|
|
383
|
+
if (key === 'ArrowDown' || key === 'ArrowUp') moveActive(key === 'ArrowDown' ? 1 : -1);
|
|
384
|
+
else {
|
|
385
|
+
activeIndex = key === 'Home' ? 0 : availableOptions.length - 1;
|
|
386
|
+
revealActive();
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (key === 'Enter' && activeIndex >= 0) {
|
|
391
|
+
const option = availableOptions[activeIndex];
|
|
392
|
+
if (!option) return;
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
event.stopPropagation();
|
|
395
|
+
handleSelect(option.value);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function handleFocusOut(event: FocusEvent): void {
|
|
400
|
+
const next = event.relatedTarget;
|
|
401
|
+
// 点击不可聚焦的选项时 relatedTarget 为空,保留弹层以完成随后的 click。
|
|
402
|
+
if (next instanceof Node && event.currentTarget instanceof HTMLElement
|
|
403
|
+
&& !event.currentTarget.contains(next)) closeMenu();
|
|
404
|
+
}
|
|
405
|
+
|
|
207
406
|
</script>
|
|
208
407
|
|
|
209
|
-
|
|
408
|
+
{#snippet optionRow(opt: Option, index: number)}
|
|
409
|
+
<Command.Item
|
|
410
|
+
id={optionId(index)}
|
|
411
|
+
disabled={select.isFetching || searchInputValue !== appliedSearch}
|
|
412
|
+
value={`${typeof opt.value}:${opt.value}`}
|
|
413
|
+
onSelect={() => handleSelect(opt.value)}
|
|
414
|
+
style={shouldVirtualize ? `height:${normalizedItemHeight}px;` : undefined}
|
|
415
|
+
class="svadmin-u-d89972fe17d6 svadmin-u-60fbb7713999 svadmin-u-50ca6ba56aa3 svadmin-u-7f6912283f11 svadmin-u-3960ffc248d9 svadmin-u-36d4469299aa svadmin-u-d5eab218aa34 svadmin-u-ec0091ee009b svadmin-u-fc7473ca09eb svadmin-u-df37b1fd9495 svadmin-u-99afb1cc3b47 svadmin-u-529780e25268"
|
|
416
|
+
>
|
|
417
|
+
<Check class="svadmin-u-d2347e8497a9 svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c {selectedValues.includes(opt.value) ? 'svadmin-u-3972e98dc84f' : 'svadmin-u-7065497e1ca0'}" />
|
|
418
|
+
{opt.label}
|
|
419
|
+
</Command.Item>
|
|
420
|
+
{/snippet}
|
|
421
|
+
|
|
422
|
+
<div class="svadmin-u-d89972fe17d6" use:menuInteractions onfocusout={handleFocusOut}
|
|
423
|
+
onkeydowncapture={handleVirtualNavigation}>
|
|
210
424
|
<div class="select-control">
|
|
211
425
|
<Button
|
|
426
|
+
bind:ref={triggerButton}
|
|
212
427
|
variant="outline"
|
|
213
428
|
type="button"
|
|
214
429
|
class="svadmin-u-6da6a3c3f741 svadmin-u-8ef2268efbbc svadmin-u-8ecebc9f80e6"
|
|
215
|
-
onclick={() => { open =
|
|
430
|
+
onclick={() => { if (open) closeMenu(); else open = true; }}
|
|
216
431
|
{...buttonRestProps}
|
|
432
|
+
onkeydown={handleTriggerKeydownWithHost}
|
|
217
433
|
{disabled}
|
|
218
434
|
aria-haspopup="listbox"
|
|
219
435
|
aria-expanded={open}
|
|
436
|
+
aria-controls={open ? listboxId : undefined}
|
|
220
437
|
aria-multiselectable={multiple || undefined}
|
|
221
438
|
aria-busy={select.isFetching}
|
|
222
439
|
aria-invalid={invalidOptions || restProps['aria-invalid']}
|
|
@@ -247,21 +464,32 @@
|
|
|
247
464
|
|
|
248
465
|
{#if open && !disabled}
|
|
249
466
|
<div class="svadmin-u-da4dbfbc4fdc svadmin-u-181b286668b5 svadmin-u-b6b02c0ebef6 svadmin-u-6da6a3c3f741 svadmin-u-421ac2be5045 svadmin-u-ca6bcd4b6f3f svadmin-u-e541d86d1ec8 svadmin-u-06bbb43166db">
|
|
250
|
-
<Command.Root shouldFilter={false} class="svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-2cd02d11d1af svadmin-u-421ac2be5045 svadmin-u-e541d86d1ec8 svadmin-u-9b13e8ae5c9c">
|
|
467
|
+
<Command.Root bind:ref={commandRoot} shouldFilter={false} class="svadmin-u-60fbb7713999 svadmin-u-8dddea0773ed svadmin-u-2cd02d11d1af svadmin-u-421ac2be5045 svadmin-u-e541d86d1ec8 svadmin-u-9b13e8ae5c9c">
|
|
251
468
|
{#if searchable}
|
|
252
469
|
<div class="svadmin-u-60fbb7713999 svadmin-u-3960ffc248d9 svadmin-u-65fdbade2025 svadmin-u-d5eab218aa34">
|
|
253
470
|
<Search class="svadmin-u-82cc6c6581cd svadmin-u-7fc7f732bf7e svadmin-u-bf600f8e029c svadmin-u-012fbd121f37 svadmin-u-0b8c506a0596" />
|
|
254
471
|
<Command.Input
|
|
472
|
+
bind:ref={searchInput}
|
|
255
473
|
bind:value={searchInputValue}
|
|
256
474
|
oninput={handleSearchInput}
|
|
475
|
+
aria-activedescendant={activeOptionId}
|
|
257
476
|
placeholder={i18n.t('common.search')}
|
|
258
477
|
class="svadmin-u-60fbb7713999 svadmin-u-e7a768f922d2 svadmin-u-6da6a3c3f741 svadmin-u-7f19cdf4c5bb svadmin-u-03b4dd7f172b svadmin-u-fc7473ca09eb svadmin-u-df37b1fd9495 svadmin-u-9c24ab70af61"
|
|
259
478
|
/>
|
|
260
479
|
</div>
|
|
261
480
|
{/if}
|
|
262
481
|
<Command.List
|
|
482
|
+
bind:ref={listElement}
|
|
483
|
+
id={listboxId}
|
|
263
484
|
role="listbox"
|
|
485
|
+
tabindex={-1}
|
|
486
|
+
aria-activedescendant={activeOptionId}
|
|
264
487
|
aria-multiselectable={multiple || undefined}
|
|
488
|
+
style={shouldVirtualize
|
|
489
|
+
? `height:${normalizedViewportHeight}px;max-height:${normalizedViewportHeight}px;overflow-y:auto;` : undefined}
|
|
490
|
+
onscroll={(event: Event) => {
|
|
491
|
+
if (event.currentTarget instanceof HTMLElement) listScrollTop = event.currentTarget.scrollTop;
|
|
492
|
+
}}
|
|
265
493
|
class="svadmin-u-558f64349245 svadmin-u-92bf82f493b1 svadmin-u-eb6a3cef9686"
|
|
266
494
|
>
|
|
267
495
|
{#if select.isLoading}
|
|
@@ -274,25 +502,28 @@
|
|
|
274
502
|
{i18n.t('select.noResults')}
|
|
275
503
|
</Command.Empty>
|
|
276
504
|
{/if}
|
|
277
|
-
{#
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
505
|
+
{#if shouldVirtualize}
|
|
506
|
+
<div style={`height:${virtualTotalHeight}px;position:relative;`}>
|
|
507
|
+
<div style={`position:absolute;top:${virtualStart * normalizedItemHeight}px;left:0;right:0;`}>
|
|
508
|
+
{#each renderedOptions as opt, offset (`${typeof opt.value}:${opt.value}`)}
|
|
509
|
+
{@render optionRow(opt, virtualStart + offset)}
|
|
510
|
+
{/each}
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
{:else}
|
|
514
|
+
{#each renderedOptions as opt, index (`${typeof opt.value}:${opt.value}`)}
|
|
515
|
+
{@render optionRow(opt, index)}
|
|
516
|
+
{/each}
|
|
517
|
+
{/if}
|
|
288
518
|
{/if}
|
|
289
519
|
</Command.List>
|
|
290
|
-
{#if hasMore}
|
|
291
|
-
<Button type="button" variant="ghost" size="sm" disabled={select.isFetching} onclick={loadMore}>
|
|
292
|
-
{i18n.t('select.loadMore')}
|
|
293
|
-
</Button>
|
|
294
|
-
{/if}
|
|
295
520
|
</Command.Root>
|
|
521
|
+
{#if hasMore}
|
|
522
|
+
<Button bind:ref={loadMoreButton} type="button" variant="ghost" size="sm"
|
|
523
|
+
aria-disabled={select.isFetching} aria-busy={select.isFetching} onclick={loadMore}>
|
|
524
|
+
{i18n.t('select.loadMore')}
|
|
525
|
+
</Button>
|
|
526
|
+
{/if}
|
|
296
527
|
</div>
|
|
297
528
|
{/if}
|
|
298
529
|
</div>
|
|
@@ -302,6 +533,11 @@
|
|
|
302
533
|
display: flex;
|
|
303
534
|
align-items: center;
|
|
304
535
|
gap: 4px;
|
|
536
|
+
min-inline-size: 0;
|
|
537
|
+
}
|
|
538
|
+
.select-control > :global(button[data-slot='button']:first-child) {
|
|
539
|
+
flex: 1 1 auto;
|
|
540
|
+
min-inline-size: 0;
|
|
305
541
|
}
|
|
306
542
|
.select-error {
|
|
307
543
|
color: var(--destructive);
|
|
@@ -11,6 +11,10 @@ interface Props extends Omit<HTMLButtonAttributes, 'value' | 'onchange' | 'child
|
|
|
11
11
|
multiple?: boolean;
|
|
12
12
|
onSearch?: (value: string) => Filter[];
|
|
13
13
|
fetchSize?: number;
|
|
14
|
+
requestTimeout?: number;
|
|
15
|
+
virtualized?: boolean;
|
|
16
|
+
itemHeight?: number;
|
|
17
|
+
viewportHeight?: number;
|
|
14
18
|
id?: string;
|
|
15
19
|
class?: string;
|
|
16
20
|
}
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
{#if adminContext.accessControlProvider}
|
|
104
104
|
<CanAccess resource={r.name} action="list">
|
|
105
105
|
<Command.Item
|
|
106
|
-
value={r.name}
|
|
106
|
+
value={`${r.name} ${r.label}`}
|
|
107
107
|
onSelect={() => act(() => navigation.list(r.name))}
|
|
108
108
|
class={itemClass}
|
|
109
109
|
>
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
</CanAccess>
|
|
114
114
|
{:else}
|
|
115
115
|
<Command.Item
|
|
116
|
-
value={r.name}
|
|
116
|
+
value={`${r.name} ${r.label}`}
|
|
117
117
|
onSelect={() => act(() => navigation.list(r.name))}
|
|
118
118
|
class={itemClass}
|
|
119
119
|
>
|
|
@@ -132,13 +132,13 @@
|
|
|
132
132
|
{#if r.canCreate !== false}
|
|
133
133
|
{#if adminContext.accessControlProvider}
|
|
134
134
|
<CanAccess resource={r.name} action="create">
|
|
135
|
-
<Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
|
|
135
|
+
<Command.Item value={`${"create-" + r.name} ${r.label}`} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
|
|
136
136
|
<Plus class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-bfa603190748" />
|
|
137
137
|
{i18n.t('common.create')} {r.label}
|
|
138
138
|
</Command.Item>
|
|
139
139
|
</CanAccess>
|
|
140
140
|
{:else}
|
|
141
|
-
<Command.Item value={"create-" + r.name} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
|
|
141
|
+
<Command.Item value={`${"create-" + r.name} ${r.label}`} onSelect={() => act(() => navigation.create(r.name))} class={itemClass}>
|
|
142
142
|
<Plus class="svadmin-u-11e59c6d5f6b svadmin-u-dc7972ebf3f3 svadmin-u-bfa603190748" />
|
|
143
143
|
{i18n.t('common.create')} {r.label}
|
|
144
144
|
</Command.Item>
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
DashboardContractError, captureAdminContext, captureAuthSession, createEnterpriseRequestContext,
|
|
5
5
|
decodeDashboardQuery, decodeDashboardSnapshot,
|
|
6
6
|
} from '@svadmin/core';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
DashboardDrilldown, DashboardMetric, DashboardProvider, DashboardQuery, DashboardSnapshot, DashboardTimeRange,
|
|
9
|
+
DashboardWidget, EnterpriseProviderRequestContext,
|
|
10
|
+
} from '@svadmin/core';
|
|
8
11
|
import { useTranslation } from '@svadmin/core/i18n';
|
|
9
12
|
import { onDestroy, untrack } from 'svelte';
|
|
10
13
|
import MetricStrip, { type MetricStripItem } from './MetricStrip.svelte';
|
|
@@ -15,15 +18,20 @@
|
|
|
15
18
|
dashboardId: string;
|
|
16
19
|
requestContext?: EnterpriseProviderRequestContext;
|
|
17
20
|
filters?: readonly { field: string; value: string | number | boolean | null }[];
|
|
21
|
+
timeRange?: DashboardTimeRange;
|
|
22
|
+
onDrillDown?: ((input: DashboardDrilldown) => void | Promise<void>) | undefined;
|
|
18
23
|
title?: string;
|
|
19
24
|
}
|
|
20
|
-
let { provider, dashboardId, requestContext, filters = [], title }: Props = $props();
|
|
25
|
+
let { provider, dashboardId, requestContext, filters = [], timeRange, onDrillDown, title }: Props = $props();
|
|
21
26
|
const context = captureAdminContext();
|
|
22
27
|
const i18n = useTranslation();
|
|
23
28
|
let snapshot = $state<DashboardSnapshot>();
|
|
29
|
+
let loadedQuery: DashboardQuery | undefined;
|
|
24
30
|
let loading = $state(false);
|
|
25
31
|
let error = $state<'load' | 'invalid'>();
|
|
32
|
+
let drillError = $state(false);
|
|
26
33
|
let epoch = 0;
|
|
34
|
+
let drillSequence = 0;
|
|
27
35
|
let mounted = true;
|
|
28
36
|
let generatedContext: EnterpriseProviderRequestContext | undefined;
|
|
29
37
|
onDestroy(() => { mounted = false; });
|
|
@@ -45,13 +53,18 @@
|
|
|
45
53
|
const request = decodeDashboardQuery({
|
|
46
54
|
dashboardId: dashboardId.trim(),
|
|
47
55
|
...(filters.length ? { filters: filters.map(filter => ({ ...filter })) } : {}),
|
|
56
|
+
...(timeRange ? { timeRange: { from: timeRange.from, to: timeRange.to } } : {}),
|
|
48
57
|
});
|
|
58
|
+
// 独立保存已校验查询;Provider 可能原位修改传入的 request。
|
|
59
|
+
const loaded = decodeDashboardQuery(request);
|
|
49
60
|
const result = decodeDashboardSnapshot(await provider.get(resolveContext(), request));
|
|
50
61
|
if (!mounted || currentEpoch !== epoch) return;
|
|
51
62
|
snapshot = result;
|
|
63
|
+
loadedQuery = loaded;
|
|
52
64
|
} catch (cause) {
|
|
53
65
|
if (!mounted || currentEpoch !== epoch) return;
|
|
54
66
|
snapshot = undefined;
|
|
67
|
+
loadedQuery = undefined;
|
|
55
68
|
error = cause instanceof DashboardContractError ? 'invalid' : 'load';
|
|
56
69
|
} finally {
|
|
57
70
|
if (mounted && currentEpoch === epoch) loading = false;
|
|
@@ -63,14 +76,58 @@
|
|
|
63
76
|
id: `${widget.id}:${metric.id}`, label: metric.label,
|
|
64
77
|
value: typeof metric.value === 'boolean' ? String(metric.value) : metric.value ?? '—',
|
|
65
78
|
...(metric.tone ? { tone: metric.tone } : {}),
|
|
66
|
-
...(
|
|
79
|
+
...(onDrillDown
|
|
80
|
+
? { onActivate: () => drillDown(widget, metric) }
|
|
81
|
+
: metric.href ? { href: metric.href } : {}),
|
|
67
82
|
})) : [],
|
|
68
83
|
) ?? []);
|
|
69
84
|
|
|
85
|
+
function drillDown(widget: DashboardWidget, metric?: DashboardMetric): void {
|
|
86
|
+
const query = loadedQuery;
|
|
87
|
+
if (!onDrillDown || !query) return;
|
|
88
|
+
const currentLoad = epoch;
|
|
89
|
+
const currentDrill = ++drillSequence;
|
|
90
|
+
drillError = false;
|
|
91
|
+
const settle = (failed: boolean): void => {
|
|
92
|
+
if (!mounted || currentLoad !== epoch || currentDrill !== drillSequence) return;
|
|
93
|
+
drillError = failed;
|
|
94
|
+
};
|
|
95
|
+
const input: DashboardDrilldown = {
|
|
96
|
+
dashboardId: query.dashboardId,
|
|
97
|
+
widget: { id: widget.id, title: widget.title, kind: widget.kind },
|
|
98
|
+
...(metric ? { metric: { ...metric } } : {}),
|
|
99
|
+
filters: (query.filters ?? []).map(filter => ({ ...filter })),
|
|
100
|
+
...(query.timeRange ? { timeRange: { ...query.timeRange } } : {}),
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const result = onDrillDown(input);
|
|
104
|
+
void Promise.resolve(result).then(() => settle(false), () => settle(true));
|
|
105
|
+
} catch { settle(true); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function trackQueryInputs(): void {
|
|
109
|
+
for (const filter of filters) {
|
|
110
|
+
void filter.field;
|
|
111
|
+
void filter.value;
|
|
112
|
+
}
|
|
113
|
+
if (timeRange) {
|
|
114
|
+
void timeRange.from;
|
|
115
|
+
void timeRange.to;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 显式跟踪嵌套查询值;Provider 执行留在下方 untrack 内,避免 effect 重入。
|
|
70
120
|
$effect(() => {
|
|
71
|
-
void provider; void dashboardId; void requestContext;
|
|
121
|
+
void provider; void dashboardId; void requestContext;
|
|
122
|
+
trackQueryInputs();
|
|
72
123
|
void captureAuthSession(context.authProvider).cacheKey;
|
|
73
|
-
untrack(() => {
|
|
124
|
+
untrack(() => {
|
|
125
|
+
drillSequence += 1;
|
|
126
|
+
drillError = false;
|
|
127
|
+
snapshot = undefined;
|
|
128
|
+
loadedQuery = undefined;
|
|
129
|
+
void load();
|
|
130
|
+
});
|
|
74
131
|
});
|
|
75
132
|
</script>
|
|
76
133
|
|
|
@@ -82,10 +139,19 @@
|
|
|
82
139
|
</Button>
|
|
83
140
|
</header>
|
|
84
141
|
{#if loading}<div role="status"><Loader2 aria-hidden="true" /> {i18n.t('common.loading')}</div>
|
|
85
|
-
{:else if error}<div role="alert">{i18n.t(`dashboard.${error}`)}
|
|
142
|
+
{:else if error}<div role="alert">{i18n.t(`dashboard.${error}`)}
|
|
143
|
+
<Button type="button" variant="outline" size="sm" onclick={() => void load()}>{i18n.t('common.retry')}</Button>
|
|
144
|
+
</div>
|
|
86
145
|
{:else if snapshot}
|
|
146
|
+
{#if drillError}<div role="alert">{i18n.t('common.operationFailed')}</div>{/if}
|
|
87
147
|
{#each snapshot.widgets.filter(widget => widget.kind === 'text') as widget (widget.id)}
|
|
88
|
-
<section aria-labelledby={`dashboard-widget-${widget.id}`}><h3 id={`dashboard-widget-${widget.id}`}>{widget.title}</h3><p>{widget.text}</p
|
|
148
|
+
<section aria-labelledby={`dashboard-widget-${widget.id}`}><h3 id={`dashboard-widget-${widget.id}`}>{widget.title}</h3><p>{widget.text}</p>
|
|
149
|
+
{#if onDrillDown}
|
|
150
|
+
<Button type="button" variant="outline" size="sm"
|
|
151
|
+
aria-label={`${i18n.t('dashboard.drilldown')}: ${widget.title}`}
|
|
152
|
+
onclick={() => drillDown(widget)}>{i18n.t('dashboard.drilldown')}</Button>
|
|
153
|
+
{/if}
|
|
154
|
+
</section>
|
|
89
155
|
{/each}
|
|
90
156
|
<MetricStrip items={metricItems} ariaLabel={title ?? i18n.t('dashboard.metrics')} />
|
|
91
157
|
{:else}<div role="status">{i18n.t('dashboard.empty')}</div>{/if}
|