@svadmin/ui 0.40.6 → 0.41.1

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.
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext, getResources, canAccessAsync, getAccessControlProvider } from '@svadmin/core';
3
- import type { Identity, MenuItem } from '@svadmin/core';
2
+ import { captureAdminContext } from '@svadmin/core';
3
+ import type { AccessControlProvider, Action, Identity, MenuItem } from '@svadmin/core';
4
4
  import { getPath } from '../router-state.svelte.js';
5
5
  import { useTranslation } from '@svadmin/core/i18n';
6
6
 
@@ -35,6 +35,7 @@
35
35
  routeMode?: 'hash' | 'path' | 'auto';
36
36
  } = $props();
37
37
  const adminContext = captureAdminContext();
38
+ const accessControlProvider = $derived(adminContext.accessControlProvider);
38
39
 
39
40
  const effectiveRouteMode = $derived(
40
41
  routeMode === 'auto'
@@ -103,13 +104,23 @@
103
104
  items: NavItem[];
104
105
  }
105
106
 
106
- async function hasCustomMenuAccess(menuItem: MenuItem): Promise<boolean> {
107
+ async function checkAccess(provider: AccessControlProvider | null, resource: string, action: Action): Promise<boolean> {
108
+ if (!provider) return true;
109
+
110
+ const result = await provider.can({
111
+ resource,
112
+ action,
113
+ meta: adminContext.getProviderMeta(resource),
114
+ });
115
+ return Array.isArray(result) ? (result[0]?.can ?? false) : result.can;
116
+ }
117
+
118
+ async function hasCustomMenuAccess(menuItem: MenuItem, provider: AccessControlProvider | null): Promise<boolean> {
107
119
  const resource = menuItem.meta?.resource;
108
120
  if (!resource) return true;
109
121
 
110
122
  try {
111
- const { can } = await canAccessAsync(resource, menuItem.meta?.action ?? 'list');
112
- return can;
123
+ return await checkAccess(provider, resource, menuItem.meta?.action ?? 'list');
113
124
  } catch {
114
125
  // Permission-provider failures must not expose protected navigation.
115
126
  return false;
@@ -128,17 +139,17 @@
128
139
  }));
129
140
  }
130
141
 
131
- async function visibleCustomMenuItem(menuItem: MenuItem): Promise<MenuItem | null> {
132
- if (menuItem.meta?.hidden || !await hasCustomMenuAccess(menuItem)) return null;
142
+ async function visibleCustomMenuItem(menuItem: MenuItem, provider: AccessControlProvider | null): Promise<MenuItem | null> {
143
+ if (menuItem.meta?.hidden || !await hasCustomMenuAccess(menuItem, provider)) return null;
133
144
  if (!menuItem.children) return menuItem;
134
145
 
135
- const children = await visibleCustomMenuItems(menuItem.children);
146
+ const children = await visibleCustomMenuItems(menuItem.children, provider);
136
147
  if (children.length === 0 && !menuItem.href) return null;
137
148
  return { ...menuItem, children };
138
149
  }
139
150
 
140
- async function visibleCustomMenuItems(menuItems: MenuItem[]): Promise<MenuItem[]> {
141
- const filteredItems = await Promise.all(menuItems.map(visibleCustomMenuItem));
151
+ async function visibleCustomMenuItems(menuItems: MenuItem[], provider: AccessControlProvider | null): Promise<MenuItem[]> {
152
+ const filteredItems = await Promise.all(menuItems.map((menuItem) => visibleCustomMenuItem(menuItem, provider)));
142
153
  return filteredItems.filter(isVisibleCustomMenuItem);
143
154
  }
144
155
 
@@ -146,13 +157,12 @@
146
157
 
147
158
  $effect(() => {
148
159
  const configuredMenu = menu?.length ? snapshotCustomMenuItems(menu) : [];
149
- // Provider swaps must invalidate checks nested below public menu groups.
150
- getAccessControlProvider();
160
+ const provider = accessControlProvider;
151
161
  customMenuItems = [];
152
162
  if (configuredMenu.length === 0) return;
153
163
 
154
164
  let cancelled = false;
155
- void visibleCustomMenuItems(configuredMenu).then((visibleItems) => {
165
+ void visibleCustomMenuItems(configuredMenu, provider).then((visibleItems) => {
156
166
  if (!cancelled) customMenuItems = visibleItems;
157
167
  });
158
168
 
@@ -163,13 +173,13 @@
163
173
 
164
174
  $effect(() => {
165
175
  const homeLabel = i18n.t('common.home');
166
- const currentResources = getResources();
176
+ const currentResources = adminContext.resources;
177
+ const provider = accessControlProvider;
167
178
  let cancelled = false;
168
179
 
169
180
  Promise.all(currentResources.map(async (r) => {
170
181
  try {
171
- const { can } = await canAccessAsync(r.name, 'list');
172
- return { r, can };
182
+ return { r, can: await checkAccess(provider, r.name, 'list') };
173
183
  } catch {
174
184
  return { r, can: false };
175
185
  }
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { Input } from './ui/input/index.js';
3
+ import { captureAdminContext } from '@svadmin/core';
3
4
  import type { ChatProvider } from '@svadmin/core';
4
5
 
5
6
  interface Props {
@@ -29,11 +30,14 @@
29
30
  name,
30
31
  type = 'text'
31
32
  }: Props = $props();
33
+ const adminContext = captureAdminContext();
32
34
 
33
35
  let suggestion = $state('');
34
36
  let isPredicting = $state(false);
35
37
  let abortController: AbortController | null = null;
36
38
  let predictTimer: ReturnType<typeof setTimeout> | null = null;
39
+ let requestEpoch = 0;
40
+ let observedValue = value;
37
41
 
38
42
  // The ghost text shows the suggestion, but only the part *after* what the user has typed
39
43
  // It only displays if the suggestion actually starts with the current value
@@ -49,66 +53,102 @@
49
53
  return '';
50
54
  });
51
55
 
52
- import { getChatProvider } from '@svadmin/core';
53
-
54
56
  const suffixText = $derived(ghostText ? ghostText.slice(value.length) : '');
57
+ const resolvedProvider = $derived(provider ?? adminContext.chatProvider);
55
58
 
56
- const resolvedProvider = $derived(provider ?? (() => {
57
- try {
58
- return getChatProvider();
59
- } catch {
60
- return null;
59
+ $effect(() => {
60
+ void resolvedProvider;
61
+ void context;
62
+ void adminContext.tenantCacheKey?.__svadminTenant;
63
+ clearSuggestionScope();
64
+
65
+ return cancelPrediction;
66
+ });
67
+
68
+ $effect(() => {
69
+ const currentValue = value;
70
+ if (currentValue !== observedValue) {
71
+ observedValue = currentValue;
72
+ clearSuggestionScope();
61
73
  }
62
- })());
74
+ });
75
+
76
+ function cancelPrediction() {
77
+ requestEpoch += 1;
78
+ abortController?.abort();
79
+ abortController = null;
80
+ if (predictTimer) clearTimeout(predictTimer);
81
+ predictTimer = null;
82
+ isPredicting = false;
83
+ }
84
+
85
+ function clearSuggestionScope() {
86
+ cancelPrediction();
87
+ suggestion = '';
88
+ }
89
+
90
+ function isCurrentPrediction(epoch: number, scopedValue: string) {
91
+ return epoch === requestEpoch && value === scopedValue;
92
+ }
63
93
 
64
94
  async function predict() {
65
- if (!resolvedProvider || !value.trim()) {
95
+ predictTimer = null;
96
+ const scopedProvider = resolvedProvider;
97
+ const scopedValue = value;
98
+ const scopedContext = context;
99
+ if (!scopedProvider || !scopedValue.trim()) {
66
100
  suggestion = '';
67
101
  return;
68
102
  }
69
103
 
104
+ cancelPrediction();
105
+ const epoch = requestEpoch;
106
+ const controller = new AbortController();
107
+ abortController = controller;
70
108
  isPredicting = true;
71
- if (abortController) {
72
- abortController.abort();
73
- }
74
- abortController = new AbortController();
109
+ suggestion = '';
75
110
 
76
111
  try {
77
- const prompt = `You are an autocomplete engine. The user is typing: "${value}".
78
- Context: ${context}.
112
+ const prompt = `You are an autocomplete engine. The user is typing: "${scopedValue}".
113
+ Context: ${scopedContext}.
79
114
  Provide ONLY the most likely completion of the exact phrase the user is typing.
80
115
  Include the user's input in your returned string.
81
116
  Do not include any other text, Markdown formatting, quotes, or explanations.`;
82
117
 
83
- const result = resolvedProvider.sendMessage(
118
+ const result = scopedProvider.sendMessage(
84
119
  [{ id: 'prompt', role: 'user', content: prompt, timestamp: Date.now() }],
85
- { signal: abortController.signal }
120
+ { signal: controller.signal }
86
121
  );
87
122
 
88
- suggestion = '';
89
-
90
- // Handle streaming or Promise responses seamlessly
91
123
  if (Symbol.asyncIterator in result) {
92
124
  for await (const chunk of result as AsyncIterable<string>) {
125
+ if (!isCurrentPrediction(epoch, scopedValue)) return;
93
126
  suggestion += chunk;
94
127
  }
95
128
  } else {
96
- suggestion = await result;
129
+ const response = await result;
130
+ if (isCurrentPrediction(epoch, scopedValue)) suggestion = response;
131
+ }
132
+
133
+ if (isCurrentPrediction(epoch, scopedValue)) {
134
+ suggestion = suggestion.trim().replace(/^["']|["']$/g, '');
97
135
  }
98
-
99
- // Clean up the suggestion
100
- suggestion = suggestion.trim().replace(/^["']|["']$/g, '');
101
136
  } catch (err: unknown) {
102
- if (err instanceof Error && err.name !== 'AbortError') {
137
+ if (isCurrentPrediction(epoch, scopedValue) && err instanceof Error && err.name !== 'AbortError') {
103
138
  suggestion = '';
104
139
  }
105
140
  } finally {
106
- isPredicting = false;
141
+ if (isCurrentPrediction(epoch, scopedValue)) {
142
+ isPredicting = false;
143
+ if (abortController === controller) abortController = null;
144
+ }
107
145
  }
108
146
  }
109
147
 
110
148
  function handleInput(e: Event) {
111
149
  const target = e.target as HTMLInputElement;
150
+ cancelPrediction();
151
+ observedValue = target.value;
112
152
  value = target.value;
113
153
 
114
154
  // Reset suggestion immediately if it no longer matches
@@ -116,19 +156,18 @@ Do not include any other text, Markdown formatting, quotes, or explanations.`;
116
156
  suggestion = '';
117
157
  }
118
158
 
119
- if (predictTimer) clearTimeout(predictTimer);
120
-
121
- // Debounce prediction
122
159
  predictTimer = setTimeout(() => {
123
- predict();
160
+ void predict();
124
161
  }, 500);
125
162
  }
126
163
 
127
164
  function handleKeyDown(e: KeyboardEvent) {
128
165
  if (e.key === 'Tab' && suffixText) {
129
- e.preventDefault(); // Prevent standard tab navigation
166
+ e.preventDefault();
167
+ cancelPrediction();
168
+ observedValue = ghostText;
130
169
  value = ghostText;
131
- suggestion = ''; // Clear suggestion once accepted
170
+ suggestion = '';
132
171
  }
133
172
  }
134
173
 
package/dist/index.d.ts CHANGED
@@ -72,7 +72,7 @@ export { default as NotificationsSettings } from "./components/NotificationsSett
72
72
  export { default as ApiSettings } from "./components/ApiSettings.svelte";
73
73
  export { default as ErrorPage } from "./components/ErrorPage.svelte";
74
74
  export { default as PermissionMatrix } from "./components/PermissionMatrix.svelte";
75
- export type { RoleInfo, ResourceInfo, ActionInfo } from "./types.js";
75
+ export type { AdminProviderBundle, RoleInfo, ResourceInfo, ActionInfo } from "./types.js";
76
76
  export { default as RolesSettings } from "./components/RolesSettings.svelte";
77
77
  export { default as AuditLogViewer } from "./components/AuditLogViewer.svelte";
78
78
  export { default as TenantSwitcher } from "./components/TenantSwitcher.svelte";
package/dist/types.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ import type { I18nProvider, ProviderBundle } from '@svadmin/core';
2
+ /**
3
+ * AdminApp 的可组合 provider 配置。
4
+ *
5
+ * 旧版顶层 props 仍然可用,且在同时传入时优先于这里的同名配置。
6
+ * Access control 仅控制浏览器端呈现,后端仍必须独立鉴权。
7
+ */
8
+ export type AdminProviderBundle = ProviderBundle & {
9
+ /** UI-only locale integration; all other fields come from Core's canonical ProviderBundle. */
10
+ readonly i18nProvider?: I18nProvider;
11
+ };
1
12
  export type RoleInfo = {
2
13
  code: string;
3
14
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.40.6",
3
+ "version": "0.41.1",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -39,7 +39,7 @@
39
39
  "./components/*": "./dist/components/*"
40
40
  },
41
41
  "peerDependencies": {
42
- "@svadmin/core": "^0.34.2",
42
+ "@svadmin/core": "^0.35.0",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.22.0",