@svadmin/ui 0.30.1 → 0.31.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.
@@ -0,0 +1,4 @@
1
+ declare module '*.css' {
2
+ const classes: { [key: string]: string };
3
+ export default classes;
4
+ }
@@ -87,6 +87,15 @@
87
87
  const resolvedRouter = $derived(routerProvider ?? createHashRouterProvider());
88
88
  const resolvedRouteMode = $derived(routeMode ?? (routerProvider ? 'auto' : 'hash'));
89
89
 
90
+ // Set up initial context synchronously so children can access resources immediately during first render
91
+ setDataProvider(dataProvider);
92
+ if (authProvider) setAuthProvider(authProvider);
93
+ setResources(resources);
94
+ setRouterProvider(resolvedRouter);
95
+ if (locale) setLocale(locale);
96
+ if (userThemeConfig) configureTheme(userThemeConfig);
97
+ if (defaultTheme) setTheme(defaultTheme);
98
+
90
99
  // Set up context — use $effect so prop changes are tracked
91
100
  $effect.pre(() => {
92
101
  setDataProvider(dataProvider);
@@ -104,7 +113,8 @@
104
113
  },
105
114
  });
106
115
 
107
- // Initialize router with provider
116
+ // Initialize router with provider synchronously
117
+ initRouter(resolvedRouter);
108
118
  $effect.pre(() => {
109
119
  initRouter(resolvedRouter);
110
120
  });
@@ -118,21 +128,35 @@
118
128
  let authChecked = $state(false);
119
129
 
120
130
  $effect(() => {
131
+ let cancelled = false;
121
132
  // Explicitly track route so auth is re-checked on navigation
122
133
  const _route = route;
123
134
 
124
135
  if (!authProvider) {
125
- isAuthenticated = true;
126
- authChecked = true;
136
+ if (!cancelled) {
137
+ isAuthenticated = true;
138
+ authChecked = true;
139
+ }
127
140
  return;
128
141
  }
129
142
  authProvider.check().then(result => {
143
+ if (cancelled) return;
130
144
  isAuthenticated = result.authenticated;
131
145
  authChecked = true;
132
146
  if (!result.authenticated && _route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password') {
133
147
  navigate(result.redirectTo ?? '/login');
134
148
  }
149
+ }).catch(err => {
150
+ if (cancelled) return;
151
+ console.warn('Auth check failed:', err);
152
+ isAuthenticated = false;
153
+ authChecked = true;
154
+ if (_route !== '/login' && _route !== '/register' && _route !== '/forgot-password' && _route !== '/update-password') {
155
+ navigate('/login');
156
+ }
135
157
  });
158
+
159
+ return () => { cancelled = true; };
136
160
  });
137
161
  </script>
138
162
 
@@ -160,7 +184,7 @@
160
184
  <Layout {title} {menu} {siteUrl} routeMode={resolvedRouteMode}>
161
185
  {#key route + (params.resource ?? '') + (params.id ?? '')}
162
186
  <div class="svadmin-page-enter">
163
- {#if currentPath().startsWith('/settings')}
187
+ {#if route.startsWith('/settings')}
164
188
  <SettingsPage />
165
189
  {:else if route === '/' || route === ''}
166
190
  {#if dashboard}
@@ -171,30 +195,30 @@
171
195
  <p class="text-muted-foreground">{t('common.dashboardHint')}</p>
172
196
  </div>
173
197
  {/if}
174
- {:else if route === '/:resource'}
198
+ {:else if route === '/:resource' || route === '/:parent/:parentId/:resource'}
175
199
  {#key params.resource}
176
200
  {@const Comp = getComponentRegistry().AutoTable}
177
201
  <Comp resourceName={params.resource} />
178
202
  {/key}
179
- {:else if route === '/:resource/create'}
203
+ {:else if route === '/:resource/create' || route === '/:parent/:parentId/:resource/create'}
180
204
  {#key params.resource}
181
205
  {@const Comp = getComponentRegistry().AutoForm}
182
206
  <Comp resourceName={params.resource} mode="create" />
183
207
  {/key}
184
- {:else if route === '/:resource/edit/:id'}
208
+ {:else if route === '/:resource/edit/:id' || route === '/:parent/:parentId/:resource/edit/:id'}
185
209
  {#key `${params.resource}-${params.id}`}
186
210
  {@const Comp = getComponentRegistry().AutoForm}
187
211
  <Comp resourceName={params.resource} mode="edit" id={params.id} />
188
212
  {/key}
189
- {:else if route === '/:resource/show/:id'}
213
+ {:else if route === '/:resource/show/:id' || route === '/:parent/:parentId/:resource/show/:id'}
190
214
  {#key `${params.resource}-${params.id}`}
191
215
  {@const Comp = getComponentRegistry().ShowPage}
192
216
  <Comp resourceName={params.resource} id={params.id} />
193
217
  {/key}
194
- {:else if route === '/:resource/clone/:id'}
218
+ {:else if route === '/:resource/clone/:id' || route === '/:parent/:parentId/:resource/clone/:id'}
195
219
  {#key `${params.resource}-clone-${params.id}`}
196
220
  {@const Comp = getComponentRegistry().AutoForm}
197
- <Comp resourceName={params.resource} mode="create" id={params.id} />
221
+ <Comp resourceName={params.resource} mode="clone" id={params.id} />
198
222
  {/key}
199
223
  {/if}
200
224
  </div>
@@ -27,7 +27,10 @@
27
27
 
28
28
  function setDensity(d: 'compact' | 'standard') {
29
29
  density = d;
30
- if (typeof window !== 'undefined') localStorage.setItem(DENSITY_KEY, d);
30
+ if (typeof window !== 'undefined') {
31
+ localStorage.setItem(DENSITY_KEY, d);
32
+ window.dispatchEvent(new CustomEvent('svadmin-density-change', { detail: d }));
33
+ }
31
34
  }
32
35
 
33
36
  // Page size
@@ -9,7 +9,7 @@
9
9
  import { Input } from './ui/input/index.js';
10
10
  import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from '@lucide/svelte';
11
11
 
12
- const authProvider = getAuthProvider();
12
+ const authProvider = getAuthProvider({ optional: true });
13
13
 
14
14
  let logs = $state<AuditLog[]>([]);
15
15
  let total = $state(0);
@@ -42,6 +42,8 @@
42
42
  }
43
43
 
44
44
  $effect(() => {
45
+ // Track `page` to re-fetch on pagination — this is the single trigger
46
+ void page;
45
47
  loadLogs();
46
48
  });
47
49
 
@@ -162,7 +164,7 @@
162
164
  <Button
163
165
  variant="outline" size="sm"
164
166
  disabled={page <= 1}
165
- onclick={() => { page--; loadLogs(); }}
167
+ onclick={() => { page--; }}
166
168
  >
167
169
  <ChevronLeft class="h-4 w-4" />
168
170
  </Button>
@@ -170,7 +172,7 @@
170
172
  <Button
171
173
  variant="outline" size="sm"
172
174
  disabled={page >= totalPages}
173
- onclick={() => { page++; loadLogs(); }}
175
+ onclick={() => { page++; }}
174
176
  >
175
177
  <ChevronRight class="h-4 w-4" />
176
178
  </Button>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { useIsAuthenticated } from '@svadmin/core';
2
+ import { useIsAuthenticated, getRouterProvider } from '@svadmin/core';
3
3
  import type { Snippet } from 'svelte';
4
4
  import { navigate } from '@svadmin/core';
5
5
 
@@ -23,8 +23,15 @@
23
23
  if (fallback) return;
24
24
  let url = redirectTo;
25
25
  if (appendCurrentPathToQuery && typeof window !== 'undefined') {
26
- const current = encodeURIComponent(window.location.pathname + window.location.search);
27
- url += `?to=${current}`;
26
+ const rp = getRouterProvider();
27
+ let currentString = window.location.pathname + window.location.search;
28
+ if (rp) {
29
+ const parsed = rp.parse();
30
+ const qs = new URLSearchParams(parsed.params).toString();
31
+ currentString = parsed.pathname + (qs ? `?${qs}` : '');
32
+ }
33
+ const current = encodeURIComponent(currentString);
34
+ url += url.includes('?') ? `&to=${current}` : `?to=${current}`;
28
35
  }
29
36
  navigate(url);
30
37
  }
@@ -17,7 +17,7 @@
17
17
  interface Props {
18
18
  resourceName: string;
19
19
  id?: string | number;
20
- mode?: 'create' | 'edit';
20
+ mode?: 'create' | 'edit' | 'clone';
21
21
  fieldRenderer?: Snippet<[{ field: FieldDefinition; value: unknown; onchange: (v: unknown) => void }]>;
22
22
  formActions?: Snippet<[{ isLoading: boolean; onSubmit: () => void }]>;
23
23
  headerContent?: Snippet;
@@ -3,7 +3,7 @@ import type { Snippet } from 'svelte';
3
3
  interface Props {
4
4
  resourceName: string;
5
5
  id?: string | number;
6
- mode?: 'create' | 'edit';
6
+ mode?: 'create' | 'edit' | 'clone';
7
7
  fieldRenderer?: Snippet<[{
8
8
  field: FieldDefinition;
9
9
  value: unknown;
@@ -6,10 +6,10 @@
6
6
  type ColumnDef,
7
7
  type SortingState,
8
8
  type RowSelectionState,
9
- type VisibilityState,
9
+ type ColumnVisibilityState,
10
10
  type ExpandedState,
11
11
  } from '@tanstack/svelte-table';
12
- import { getCoreRowModel } from '@tanstack/table-core';
12
+ import { createCoreRowModel } from '@tanstack/table-core';
13
13
 
14
14
  import { useList, useDelete, getResource } from '@svadmin/core';
15
15
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
@@ -101,6 +101,19 @@
101
101
  let filters = $state<Filter[]>([]);
102
102
  let searchText = $state(urlState.search ?? '');
103
103
 
104
+ // Sync external controlled state
105
+ $effect(() => {
106
+ if (externalPagination) {
107
+ pagination = externalPagination;
108
+ }
109
+ });
110
+
111
+ $effect(() => {
112
+ if (externalSorters) {
113
+ sorters = externalSorters;
114
+ }
115
+ });
116
+
104
117
  // URL sync
105
118
  $effect(() => {
106
119
  writeURLState({
@@ -168,7 +181,7 @@
168
181
  let sorting = $state<SortingState>(untrack(() =>
169
182
  sorters.map(s => ({ id: s.field, desc: s.order === 'desc' }))
170
183
  ));
171
- let columnVisibility = $state<VisibilityState>((() => {
184
+ let columnVisibility = $state<ColumnVisibilityState>((() => {
172
185
  // Try to restore from localStorage first
173
186
  const storageKey = `svadmin-columns-${resourceName}`;
174
187
  if (typeof window !== 'undefined') {
@@ -178,7 +191,7 @@
178
191
  } catch { /* ignore */ }
179
192
  }
180
193
  // Fall back to resource field definitions
181
- const vis: VisibilityState = {};
194
+ const vis: ColumnVisibilityState = {};
182
195
  for (const f of resource.fields) {
183
196
  if (f.showInList === false) vis[f.key] = false;
184
197
  }
@@ -214,7 +227,7 @@
214
227
  resource.fields.filter(f => f.showInList !== false)
215
228
  );
216
229
 
217
- const columns = $derived<ColumnDef<Record<string, unknown>>[]>([
230
+ const columns = $derived<ColumnDef<any, Record<string, unknown>>[]>([
218
231
  // Selection column
219
232
  ...(selectable && canDelete ? [{
220
233
  id: '_select',
@@ -222,7 +235,7 @@
222
235
  cell: () => '',
223
236
  size: 40,
224
237
  enableSorting: false,
225
- } satisfies ColumnDef<Record<string, unknown>>] : []),
238
+ } satisfies ColumnDef<any, Record<string, unknown>>] : []),
226
239
  // Expand column
227
240
  ...(expandedRowRender ? [{
228
241
  id: '_expand',
@@ -230,9 +243,9 @@
230
243
  cell: () => '',
231
244
  size: 40,
232
245
  enableSorting: false,
233
- } satisfies ColumnDef<Record<string, unknown>>] : []),
246
+ } satisfies ColumnDef<any, Record<string, unknown>>] : []),
234
247
  // Data columns
235
- ...visibleFields.map((field): ColumnDef<Record<string, unknown>> => ({
248
+ ...visibleFields.map((field): ColumnDef<any, Record<string, unknown>> => ({
236
249
  id: field.key,
237
250
  accessorKey: field.key,
238
251
  header: () => field.label,
@@ -252,10 +265,10 @@
252
265
  const tbl = createTable({
253
266
  get data() { return query.data?.data ?? []; },
254
267
  get columns() { return columns; },
255
- getCoreRowModel: getCoreRowModel(),
268
+ getCoreRowModel: createCoreRowModel(),
256
269
  manualPagination: true,
257
270
  manualSorting: true,
258
- getRowId: (row) => String(row[primaryKey]),
271
+ getRowId: (row: any) => String(row[primaryKey]),
259
272
  state: {
260
273
  get sorting() { return sorting; },
261
274
  get columnVisibility() { return columnVisibility; },
@@ -263,19 +276,19 @@
263
276
  get expanded() { return expanded; },
264
277
  get columnOrder() { return columnOrder; },
265
278
  },
266
- onSortingChange: (updater) => {
279
+ onSortingChange: (updater: any) => {
267
280
  sorting = typeof updater === 'function' ? updater(sorting) : updater;
268
281
  },
269
- onColumnVisibilityChange: (updater) => {
282
+ onColumnVisibilityChange: (updater: any) => {
270
283
  columnVisibility = typeof updater === 'function' ? updater(columnVisibility) : updater;
271
284
  },
272
- onRowSelectionChange: (updater) => {
285
+ onRowSelectionChange: (updater: any) => {
273
286
  rowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
274
287
  },
275
- onExpandedChange: (updater) => {
288
+ onExpandedChange: (updater: any) => {
276
289
  expanded = typeof updater === 'function' ? updater(expanded) : updater;
277
290
  },
278
- onColumnOrderChange: (updater) => {
291
+ onColumnOrderChange: (updater: any) => {
279
292
  columnOrder = typeof updater === 'function' ? updater(columnOrder) : updater;
280
293
  },
281
294
  get enableRowSelection() { return selectable && canDelete; },
@@ -390,7 +403,7 @@
390
403
  {/snippet}
391
404
  </DropdownMenu.Trigger>
392
405
  <DropdownMenu.Content align="end" class="w-48">
393
- {#each tbl.getAllLeafColumns().filter(c => !c.id.startsWith('_')) as column}
406
+ {#each tbl.getAllLeafColumns().filter((c: any) => !c.id.startsWith('_')) as column}
394
407
  <DropdownMenu.CheckboxItem
395
408
  checked={column.getIsVisible()}
396
409
  onCheckedChange={(v) => column.toggleVisibility(!!v)}
@@ -515,9 +528,9 @@
515
528
  <Table.Header>
516
529
  {#each tbl.getHeaderGroups() as headerGroup}
517
530
  <DraggableHeader
518
- columns={headerGroup.headers.map(h => ({ id: h.column.id, header: h }))}
531
+ columns={headerGroup.headers.map((h: any) => ({ id: h.column.id, header: h }))}
519
532
  resourceName={resourceName}
520
- onReorder={(newOrder) => { columnOrder = newOrder.map(c => c.id); }}
533
+ onReorder={(newOrder) => { columnOrder = newOrder.map((c: any) => c.id); }}
521
534
  >
522
535
  {#snippet header(col, index, dragProps)}
523
536
  {@const header = col.header as typeof headerGroup.headers[0]}
@@ -1,41 +1,51 @@
1
1
  <script lang="ts">
2
2
  import { getResources } from '@svadmin/core';
3
- import { currentPath } from '@svadmin/core/router';
3
+ import { getPath } from '../router-state.svelte.js';
4
+ import { formatLink } from '@svadmin/core/router';
4
5
  import { t } from '@svadmin/core/i18n';
5
6
  import * as Breadcrumb from './ui/breadcrumb/index.js';
6
7
 
7
8
  const resources = getResources();
8
- let path = $state(currentPath());
9
-
10
- function onHashChange() { path = currentPath(); }
11
9
 
12
10
  interface Crumb { label: string; href: string; }
13
11
 
14
12
  const crumbs = $derived.by(() => {
15
- const result: Crumb[] = [{ label: t('common.home'), href: '#/' }];
16
- if (path === '/') return result;
13
+ const result: Crumb[] = [{ label: t('common.home') ?? 'Home', href: formatLink('/') }];
14
+ const path = getPath().replace(/^\//, '');
15
+ if (path === '') return result;
17
16
 
18
17
  const segments = path.split('/').filter(Boolean);
19
- const resourceName = segments[0];
20
- const res = resources.find(r => r.name === resourceName);
21
-
22
- if (res) {
23
- result.push({ label: res.label, href: `#/${res.name}` });
24
-
25
- if (segments[1] === 'create') {
26
- result.push({ label: t('common.create'), href: `#/${res.name}/create` });
27
- } else if (segments[1] === 'edit' && segments[2]) {
28
- result.push({ label: `${t('common.edit')} #${segments[2]}`, href: `#/${res.name}/edit/${segments[2]}` });
29
- } else if (segments[1] === 'show' && segments[2]) {
30
- result.push({ label: `${t('common.detail')} #${segments[2]}`, href: `#/${res.name}/show/${segments[2]}` });
31
- }
18
+ const resourceNames = resources.map(r => r.name);
19
+
20
+ let currentPath = '';
21
+
22
+ for (let i = 0; i < segments.length; i++) {
23
+ const seg = segments[i];
24
+ currentPath += `/${seg}`;
25
+
26
+ if (resourceNames.includes(seg)) {
27
+ const res = resources.find(r => r.name === seg)!;
28
+ result.push({ label: res.label, href: formatLink(currentPath) });
29
+ } else if (i > 0 && resourceNames.includes(segments[i-1])) {
30
+ // This is a parent ID (e.g. /teams/123/users)
31
+ result.push({ label: `#${seg}`, href: formatLink(currentPath) });
32
+ } else if (seg === 'create') {
33
+ result.push({ label: t('common.create') ?? 'Create', href: formatLink(currentPath) });
34
+ } else if (['edit', 'show', 'clone'].includes(seg) && segments[i+1]) {
35
+ const actionLabel = seg === 'edit' ? (t('common.edit') ?? 'Edit') :
36
+ seg === 'show' ? (t('common.detail') ?? 'Details') :
37
+ (t('common.clone') ?? 'Clone');
38
+ const id = segments[i+1];
39
+ currentPath += `/${id}`; // advance path by id
40
+ result.push({ label: `${actionLabel} #${id}`, href: formatLink(currentPath) });
41
+ i++; // skip next segment since we consumed the id
42
+ }
32
43
  }
44
+
33
45
  return result;
34
46
  });
35
47
  </script>
36
48
 
37
- <svelte:window onhashchange={onHashChange} />
38
-
39
49
  {#if crumbs.length > 1}
40
50
  <Breadcrumb.Root class="mb-4">
41
51
  <Breadcrumb.List>
@@ -94,6 +94,8 @@
94
94
  }
95
95
  }
96
96
  }, 300);
97
+
98
+ return () => { if (persistTimer) clearTimeout(persistTimer); };
97
99
  });
98
100
 
99
101
  function genId(): string {
@@ -29,10 +29,13 @@
29
29
  let dragIndex = $state<number | null>(null);
30
30
  let dropIndex = $state<number | null>(null);
31
31
 
32
- // Restore saved order from localStorage
32
+ // Restore saved order from localStorage (once on mount)
33
+ let _restored = false;
33
34
  $effect(() => {
35
+ if (_restored) return;
34
36
  const storageKey = `svadmin-colorder-${resourceName}`;
35
37
  if (typeof window === 'undefined') return;
38
+ _restored = true;
36
39
  try {
37
40
  const saved = localStorage.getItem(storageKey);
38
41
  if (saved) {
@@ -27,7 +27,7 @@
27
27
  }>();
28
28
 
29
29
  // Retrieve optional component overrides from registry
30
- const registry = getComponentRegistry();
30
+ const registry = getComponentRegistry() || {} as any;
31
31
  const CustomBreadcrumbs = registry.Breadcrumbs;
32
32
  const CustomThemeToggle = registry.ThemeToggle;
33
33
  const CustomUserMenu = registry.UserMenu;
@@ -35,22 +35,20 @@
35
35
  let identity = $state<Identity | null>(null);
36
36
 
37
37
  $effect(() => {
38
+ let cancelled = false;
38
39
  if (!auth) {
39
- loading = false;
40
+ if (!cancelled) loading = false;
40
41
  return;
41
42
  }
42
- (async () => {
43
- try {
44
- const { authenticated, redirectTo } = await auth.check();
45
- if (!authenticated) {
46
- navigate(redirectTo ?? '/login');
47
- return;
48
- }
49
- identity = await auth.getIdentity();
50
- } finally {
43
+ auth.getIdentity().then(id => {
44
+ if (!cancelled) {
45
+ identity = id;
51
46
  loading = false;
52
47
  }
53
- })();
48
+ }).catch(() => {
49
+ if (!cancelled) loading = false;
50
+ });
51
+ return () => { cancelled = true; };
54
52
  });
55
53
 
56
54
  async function handleLogout() {
@@ -2,6 +2,7 @@
2
2
  import { Shield, AlertCircle, Check } from '@lucide/svelte';
3
3
  import type { Snippet } from 'svelte';
4
4
  import type { RoleInfo, ResourceInfo, ActionInfo } from '../types.js';
5
+ import { t } from '@svadmin/core/i18n';
5
6
 
6
7
  let {
7
8
  // Basic metadata lists
@@ -51,9 +52,9 @@
51
52
  <div>
52
53
  <h1 class="text-2xl font-bold text-foreground flex items-center gap-2">
53
54
  <Shield class="w-6 h-6 text-primary" />
54
- 统一操作权限矩阵
55
+ {t('permissions.title') || 'Permission Matrix'}
55
56
  </h1>
56
- <p class="text-muted-foreground mt-1 text-sm">直观配置系统模块与角色的鉴权规则网络。</p>
57
+ <p class="text-muted-foreground mt-1 text-sm">{t('permissions.description') || 'Configure authorization rules for system modules and roles.'}</p>
57
58
  </div>
58
59
 
59
60
  <!-- Toast Feedback -->
@@ -72,7 +73,7 @@
72
73
  <div class="flex flex-col md:flex-row gap-6 flex-1 min-h-[500px]">
73
74
  <!-- Left: Role Selector Sidebar -->
74
75
  <div class="w-full md:w-64 flex-shrink-0 bg-card rounded-xl shadow-sm border border-border p-4 flex flex-col">
75
- <h2 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-2">业务受众角色</h2>
76
+ <h2 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-2">{t('permissions.roles') || 'Roles'}</h2>
76
77
  <div class="space-y-1 flex-1 overflow-y-auto">
77
78
  {#each roles as role}
78
79
  <button
@@ -84,7 +85,7 @@
84
85
  </button>
85
86
  {/each}
86
87
  {#if roles.length === 0}
87
- <p class="text-center text-muted-foreground text-sm p-4">暂无角色</p>
88
+ <p class="text-center text-muted-foreground text-sm p-4">{t('permissions.noRoles') || 'No roles available'}</p>
88
89
  {/if}
89
90
  </div>
90
91
 
@@ -102,10 +103,10 @@
102
103
  <div class="p-5 border-b border-border bg-muted/30 flex items-center justify-between">
103
104
  <div class="flex items-center gap-4">
104
105
  <div>
105
- <h2 class="text-lg font-bold text-foreground">当前视角:
106
- <span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || '未选择'}</span>
106
+ <h2 class="text-lg font-bold text-foreground">{t('permissions.currentRole') || 'Current role'}:
107
+ <span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || (t('permissions.noneSelected') || 'None selected')}</span>
107
108
  </h2>
108
- <p class="text-xs text-muted-foreground mt-1">勾选以即时挂载/收回操作权限</p>
109
+ <p class="text-xs text-muted-foreground mt-1">{t('permissions.toggleHint') || 'Toggle checkboxes to grant or revoke permissions'}</p>
109
110
  </div>
110
111
 
111
112
  <!-- Slot for header extra logic -->
@@ -116,7 +117,7 @@
116
117
 
117
118
  {#if conflictCount > 0}
118
119
  <div class="px-3 py-1.5 bg-destructive/10 text-destructive rounded-lg text-xs font-medium ring-1 ring-destructive/20 flex items-center gap-1.5">
119
- <AlertCircle class="w-4 h-4" /> {conflictCount} 重叠/冲突规则
120
+ <AlertCircle class="w-4 h-4" /> {conflictCount} {t('permissions.conflicts') || 'conflicting rules'}
120
121
  </div>
121
122
  {/if}
122
123
  </div>
@@ -126,7 +127,7 @@
126
127
  <table class="min-w-full rounded-lg overflow-hidden shadow-sm ring-1 ring-border/10">
127
128
  <thead class="bg-muted/50">
128
129
  <tr>
129
- <th scope="col" class="py-3 pr-3 pl-4 text-left text-sm font-semibold text-foreground w-48">模块域与资源点</th>
130
+ <th scope="col" class="py-3 pr-3 pl-4 text-left text-sm font-semibold text-foreground w-48">{t('permissions.resources') || 'Resources'}</th>
130
131
  {#each actions as action}
131
132
  <th scope="col" class="px-2 py-3 text-center text-sm font-semibold text-foreground">
132
133
  <div class="flex flex-col items-center">
@@ -140,7 +141,7 @@
140
141
  <tbody class="bg-card">
141
142
  {#if resources.length === 0}
142
143
  <tr>
143
- <td colspan={actions.length + 1} class="py-10 text-center text-muted-foreground">暂无可用资源</td>
144
+ <td colspan={actions.length + 1} class="py-10 text-center text-muted-foreground">{t('permissions.noResources') || 'No resources available'}</td>
144
145
  </tr>
145
146
  {/if}
146
147
  {#each resources as resource, i}
@@ -7,7 +7,7 @@
7
7
  import { toast } from '@svadmin/core/toast';
8
8
  import { AlertCircle } from '@lucide/svelte';
9
9
 
10
- const authProvider = getAuthProvider();
10
+ const authProvider = getAuthProvider({ optional: true });
11
11
  const rawResources = getResources();
12
12
 
13
13
  let roles = $state<RoleInfo[]>([]);
@@ -8,6 +8,9 @@
8
8
  import AboutSettings from './AboutSettings.svelte';
9
9
  import RolesSettings from './RolesSettings.svelte';
10
10
  import AuditLogViewer from './AuditLogViewer.svelte';
11
+ import { getAuthProvider } from '@svadmin/core';
12
+
13
+ const authProvider = getAuthProvider({ optional: true });
11
14
 
12
15
  const sections = [
13
16
  {
@@ -20,26 +23,29 @@
20
23
  {
21
24
  group: 'settings.system',
22
25
  items: [
23
- { key: 'roles', path: '/settings/roles', icon: Shield, label: 'settings.rolesAndPermissions' },
24
- { key: 'audit', path: '/settings/audit', icon: FileSearch, label: 'settings.auditLogs' },
26
+ ...(authProvider ? [
27
+ { key: 'roles', path: '/settings/roles', icon: Shield, label: 'settings.rolesAndPermissions' },
28
+ { key: 'audit', path: '/settings/audit', icon: FileSearch, label: 'settings.auditLogs' },
29
+ ] : []),
25
30
  { key: 'about', path: '/settings/about', icon: Info, label: 'settings.about' },
26
31
  ],
27
32
  },
28
33
  ];
29
34
 
35
+ import { getParams, getRoute } from '../router-state.svelte.js';
36
+
30
37
  let activeKey = $derived.by(() => {
31
- const path = currentPath();
32
- if (path.includes('/settings/appearance')) return 'appearance';
33
- if (path.includes('/settings/roles')) return 'roles';
34
- if (path.includes('/settings/audit')) return 'audit';
35
- if (path.includes('/settings/about')) return 'about';
38
+ const tab = getParams().tab;
39
+ if (tab === 'appearance') return 'appearance';
40
+ if (tab === 'roles') return 'roles';
41
+ if (tab === 'audit') return 'audit';
42
+ if (tab === 'about') return 'about';
36
43
  return 'profile';
37
44
  });
38
45
 
39
46
  // Default redirect to profile
40
47
  $effect(() => {
41
- const path = currentPath();
42
- if (path === '/settings') {
48
+ if (getRoute() === '/settings') {
43
49
  navigate('/settings/profile');
44
50
  }
45
51
  });
@@ -11,6 +11,7 @@
11
11
  import ListButton from './buttons/ListButton.svelte';
12
12
  import EditButton from './buttons/EditButton.svelte';
13
13
  import DeleteButton from './buttons/DeleteButton.svelte';
14
+ import CloneButton from './buttons/CloneButton.svelte';
14
15
  import RefreshButton from './buttons/RefreshButton.svelte';
15
16
 
16
17
  let { resourceName, id } = $props<{ resourceName: string; id: string | number }>();
@@ -28,6 +29,9 @@
28
29
  {#if resource.canEdit !== false}
29
30
  <EditButton resource={resourceName} recordItemId={id} hideText />
30
31
  {/if}
32
+ {#if resource.canCreate !== false}
33
+ <CloneButton resource={resourceName} recordItemId={id} hideText />
34
+ {/if}
31
35
  {#if resource.canDelete !== false}
32
36
  <DeleteButton resource={resourceName} recordItemId={id} hideText onSuccess={() => navigate(`/${resourceName}`)} />
33
37
  {/if}
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { getResources, canAccessAsync } from '@svadmin/core';
3
3
  import type { Identity, MenuItem } from '@svadmin/core';
4
- import { currentPath, navigate } from '@svadmin/core/router';
4
+ import { navigate } from '@svadmin/core/router';
5
+ import { getPath } from '../router-state.svelte.js';
5
6
  import { t, getLocale, setLocale, getAvailableLocales } from '@svadmin/core/i18n';
6
7
  import { toggleTheme, getResolvedTheme, colorThemes, getColorTheme, setColorTheme } from '@svadmin/core';
7
8
  import { Button } from './ui/button/index.js';
@@ -118,9 +119,7 @@
118
119
  const localeLabel = $derived(getLocale() === 'zh-CN' ? '中' : 'EN');
119
120
 
120
121
  // Track current hash for active state
121
- let path = $state(currentPath());
122
-
123
- function onHashChange() { path = currentPath(); }
122
+ const path = $derived(getPath());
124
123
 
125
124
  function isActive(itemPath: string): boolean {
126
125
  if (itemPath === '/') return path === '/';
@@ -159,6 +158,26 @@
159
158
  let colorPickerRef = $state<HTMLDivElement | null>(null);
160
159
  let colorPickerOpenedAt = 0;
161
160
 
161
+ // Density state
162
+ let density = $state<'compact' | 'standard'>('standard');
163
+
164
+ $effect(() => {
165
+ if (typeof window !== 'undefined') {
166
+ const stored = localStorage.getItem('svadmin-sidebar-density');
167
+ if (stored === 'compact' || stored === 'standard') density = stored;
168
+
169
+ const onDensityChange = (e: Event) => {
170
+ const customEvent = e as CustomEvent<'compact' | 'standard'>;
171
+ density = customEvent.detail;
172
+ };
173
+ window.addEventListener('svadmin-density-change', onDensityChange);
174
+ return () => window.removeEventListener('svadmin-density-change', onDensityChange);
175
+ }
176
+ });
177
+
178
+ const pyClass = $derived(density === 'compact' ? 'py-1.5' : 'py-2.5');
179
+ const pyClassGroupItem = $derived(density === 'compact' ? 'py-1.5' : 'py-2');
180
+
162
181
  // Click-outside to close color picker
163
182
  $effect(() => {
164
183
  if (!colorPickerOpen) return;
@@ -176,7 +195,7 @@
176
195
  });
177
196
  </script>
178
197
 
179
- <svelte:window onhashchange={onHashChange} />
198
+
180
199
 
181
200
  <aside
182
201
  aria-label="Sidebar navigation"
@@ -235,7 +254,7 @@
235
254
  {@const active = isActive(item.path)}
236
255
  <a
237
256
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
238
- class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
257
+ class="flex items-center gap-3 rounded-lg px-3 {pyClassGroupItem} text-sm font-medium transition-all duration-200
239
258
  {active
240
259
  ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
241
260
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
@@ -258,7 +277,7 @@
258
277
  <a
259
278
  {...props}
260
279
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
261
- class="flex items-center justify-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
280
+ class="flex items-center justify-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
262
281
  {active
263
282
  ? 'bg-sidebar-accent text-sidebar-accent-foreground'
264
283
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
@@ -274,7 +293,7 @@
274
293
  {:else}
275
294
  <a
276
295
  href={effectiveRouteMode === 'hash' ? `#${item.path}` : item.path}
277
- class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
296
+ class="flex items-center gap-3 rounded-lg px-3 {pyClass} text-sm font-medium transition-all duration-200
278
297
  {active
279
298
  ? 'bg-sidebar-accent text-sidebar-accent-foreground sidebar-nav-active'
280
299
  : 'text-sidebar-foreground/70 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'}"
@@ -9,6 +9,8 @@
9
9
  ChevronDown, Folder, ExternalLink, type Icon as LucideIcon,
10
10
  } from '@lucide/svelte';
11
11
 
12
+ import { formatLink } from '@svadmin/core/router';
13
+
12
14
  let { item, currentPath, collapsed = false, depth = 0 }: {
13
15
  item: MenuItem;
14
16
  currentPath: string;
@@ -37,6 +39,7 @@
37
39
  function isActive(itemHref: string | undefined): boolean {
38
40
  if (!itemHref) return false;
39
41
  if (itemHref === '/') return currentPath === '/';
42
+ // Remove hash/leading slash for comparison if necessary, but currentPath usually starts with /
40
43
  return currentPath.startsWith(itemHref);
41
44
  }
42
45
 
@@ -49,7 +52,9 @@
49
52
  const hasChildren = $derived(item.children && item.children.length > 0);
50
53
  const active = $derived(isActive(item.href));
51
54
  const childActive = $derived(hasActiveChild(item));
52
- const isExternal = $derived(item.target === '_blank');
55
+ const isExternal = $derived(item.target === '_blank' || item.href?.startsWith('http'));
56
+
57
+ const finalHref = $derived(isExternal ? item.href : (item.href ? formatLink(item.href) : undefined));
53
58
 
54
59
  // Track open state — auto-open if a child is active
55
60
  let isOpen = $state(false);
@@ -92,7 +97,7 @@
92
97
  {#snippet child({ props }: { props: Record<string, unknown> })}
93
98
  <a
94
99
  {...props}
95
- href={item.href ? `#${item.href}` : undefined}
100
+ href={finalHref}
96
101
  target={isExternal ? '_blank' : undefined}
97
102
  rel={isExternal ? 'noopener noreferrer' : undefined}
98
103
  class="flex items-center justify-center rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200
@@ -111,7 +116,7 @@
111
116
  {:else}
112
117
  <!-- Leaf node: render as a link -->
113
118
  <a
114
- href={item.href ? `#${item.href}` : undefined}
119
+ href={finalHref}
115
120
  target={isExternal ? '_blank' : undefined}
116
121
  rel={isExternal ? 'noopener noreferrer' : undefined}
117
122
  class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { cn, type WithElementRef } from "../../../utils.js";
3
3
  import type { HTMLButtonAttributes } from "svelte/elements";
4
+ import { getContext } from "svelte";
4
5
 
5
6
  type Props = WithElementRef<HTMLButtonAttributes, HTMLButtonElement>;
6
7
 
@@ -11,16 +12,25 @@
11
12
  child,
12
13
  ...restProps
13
14
  }: Props & { child?: import("svelte").Snippet<[{ props: Record<string, unknown> }]> } = $props();
15
+
16
+ const toggle = getContext<() => void>("svadmin-dropdown-toggle");
17
+
18
+ function handleClick(e: MouseEvent) {
19
+ e.stopPropagation();
20
+ toggle?.();
21
+ restProps.onclick?.(e as any);
22
+ }
14
23
  </script>
15
24
 
16
25
  {#if child}
17
- {@render child({ props: { ...restProps, class: cn(className), "data-slot": "dropdown-menu-trigger" } })}
26
+ {@render child({ props: { ...restProps, class: cn(className), "data-slot": "dropdown-menu-trigger", onclick: handleClick } })}
18
27
  {:else}
19
28
  <button
20
29
  bind:this={ref}
21
30
  data-slot="dropdown-menu-trigger"
22
31
  class={cn(className)}
23
32
  {...restProps}
33
+ onclick={handleClick}
24
34
  >
25
35
  {@render children?.()}
26
36
  </button>
@@ -26,6 +26,10 @@
26
26
  }
27
27
 
28
28
  setContext("svadmin-dropdown-open", () => open);
29
+ setContext("svadmin-dropdown-toggle", () => {
30
+ open = !open;
31
+ onOpenChange?.(open);
32
+ });
29
33
 
30
34
  $effect(() => {
31
35
  if (open) {
@@ -1,5 +1,6 @@
1
1
  import type { RouterProvider } from '@svadmin/core';
2
2
  export declare function initRouter(provider?: RouterProvider): void;
3
3
  export declare function getRoute(): string;
4
+ export declare function getPath(): string;
4
5
  export declare function getParams(): Record<string, string>;
5
6
  export declare function getRouterProviderInstance(): RouterProvider | undefined;
@@ -2,6 +2,7 @@
2
2
  import { matchRoute, setActiveRouterProvider } from '@svadmin/core/router';
3
3
  let _route = $state('/');
4
4
  let _params = $state({});
5
+ let _path = $state('/');
5
6
  let _initialized = false;
6
7
  let _provider;
7
8
  const ROUTES = [
@@ -9,6 +10,8 @@ const ROUTES = [
9
10
  '/register',
10
11
  '/forgot-password',
11
12
  '/update-password',
13
+ '/settings',
14
+ '/settings/:tab',
12
15
  '/',
13
16
  '/:resource',
14
17
  '/:resource/create',
@@ -28,12 +31,14 @@ function sync() {
28
31
  path = parsed.pathname || '/';
29
32
  const m = matchRoute(path, ROUTES);
30
33
  _route = m?.route ?? '/';
34
+ _path = path;
31
35
  _params = { ...(m?.params ?? {}), ...parsed.params };
32
36
  }
33
37
  else {
34
38
  path = typeof window !== 'undefined' ? window.location.hash.replace(/^#/, '') || '/' : '/';
35
39
  const m = matchRoute(path, ROUTES);
36
40
  _route = m?.route ?? '/';
41
+ _path = path;
37
42
  _params = m?.params ?? {};
38
43
  }
39
44
  }
@@ -52,6 +57,9 @@ export function initRouter(provider) {
52
57
  export function getRoute() {
53
58
  return _route;
54
59
  }
60
+ export function getPath() {
61
+ return _path;
62
+ }
55
63
  export function getParams() {
56
64
  return _params;
57
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "main": "dist/index.js",
13
- "types": "dist/index.d.d.ts",
13
+ "types": "dist/index.d.ts",
14
14
  "exports": {
15
15
  ".": {
16
16
  "types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "./components/*": "./dist/components/*"
26
26
  },
27
27
  "peerDependencies": {
28
- "@svadmin/core": "^0.21.1",
28
+ "@svadmin/core": "^0.22.0",
29
29
  "@tanstack/svelte-query": "^6.0.0",
30
30
  "highlight.js": "^11.11.1",
31
31
  "isomorphic-dompurify": "^3.7.1",
@@ -53,7 +53,6 @@
53
53
  "dependencies": {
54
54
  "@lucide/svelte": "^1.7.0",
55
55
  "@tanstack/svelte-table": "^9.0.0-alpha.10",
56
- "@tanstack/table-core": "^9.0.0-alpha.10",
57
56
  "bits-ui": "^2.17.1",
58
57
  "clsx": "^2.1.1",
59
58
  "cmdk-sv": "^0.0.19",
@@ -72,7 +71,9 @@
72
71
  "bugs": {
73
72
  "url": "https://github.com/zuohuadong/svadmin/issues"
74
73
  },
75
- "devDependencies": {},
74
+ "devDependencies": {
75
+ "@tanstack/table-core": "8.21.3"
76
+ },
76
77
  "scripts": {
77
78
  "build": "svelte-package -i src -o dist"
78
79
  }