@svadmin/ui 0.41.0 → 0.42.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.
@@ -262,10 +262,12 @@
262
262
  const acEnabled = $derived(!!adminContext.accessControlProvider);
263
263
  const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
264
264
  const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
265
+ const canShowPerm = useCan(() => ({ resource: resourceName, action: 'show', queryOptions: { enabled: acEnabled } }));
265
266
  const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
266
267
  const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
267
268
  const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
268
269
  const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
270
+ const canShow = $derived(canShowPerm.allowed && resource.canShow !== false);
269
271
  const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
270
272
  const canExport = $derived(canExportPerm.allowed);
271
273
 
@@ -837,9 +839,11 @@
837
839
  <Pencil class="h-4 w-4" /> {i18n.t('common.edit')}
838
840
  </ContextMenu.Item>
839
841
  {/if}
840
- <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)} class="gap-2">
841
- <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
842
- </ContextMenu.Item>
842
+ {#if canShow}
843
+ <ContextMenu.Item onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)} class="gap-2">
844
+ <Eye class="h-4 w-4" /> {i18n.t('common.detail')}
845
+ </ContextMenu.Item>
846
+ {/if}
843
847
  <ContextMenu.Item onclick={() => navigator.clipboard?.writeText(String(id))} class="gap-2">
844
848
  <Copy class="h-4 w-4" /> {i18n.t('common.copyId')}
845
849
  </ContextMenu.Item>
@@ -913,9 +917,11 @@
913
917
  <Pencil class="h-4 w-4" />
914
918
  </TooltipButton>
915
919
  {/if}
916
- <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)}>
917
- <Eye class="h-4 w-4" />
918
- </TooltipButton>
920
+ {#if canShow}
921
+ <TooltipButton tooltip={i18n.t('common.detail')} variant="ghost" size="icon-sm" onclick={() => adminContext.navigate(`/${resourceName}/show/${id}`)}>
922
+ <Eye class="h-4 w-4" />
923
+ </TooltipButton>
924
+ {/if}
919
925
  {#if canDelete}
920
926
  <TooltipButton tooltip={i18n.t('common.delete')} variant="ghost" size="icon-sm" onclick={() => confirmDelete(id)} class="hover:text-destructive">
921
927
  <Trash2 class="h-4 w-4" />
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext } from '@svadmin/core';
2
+ import { captureAdminContext, queryKeyMatches } from '@svadmin/core';
3
3
  import { useQueryClient } from '@tanstack/svelte-query';
4
4
  import { useTranslation } from '@svadmin/core/i18n';
5
5
 
@@ -39,13 +39,19 @@
39
39
  await taskProvider.cancel(taskId);
40
40
  await Promise.all([
41
41
  queryClient.invalidateQueries({
42
- predicate: (query) => query.queryKey[0] === 'taskList'
43
- && adminContext.matchesTenantQuery(query.queryKey),
42
+ predicate: (query) => queryKeyMatches(query.queryKey, {
43
+ ...adminContext.queryKeyMatcher(),
44
+ kind: 'task',
45
+ action: 'list',
46
+ }),
44
47
  }),
45
48
  queryClient.invalidateQueries({
46
- predicate: (query) => query.queryKey[0] === 'task'
47
- && query.queryKey[1] === taskId
48
- && adminContext.matchesTenantQuery(query.queryKey),
49
+ predicate: (query) => queryKeyMatches(query.queryKey, {
50
+ ...adminContext.queryKeyMatcher(),
51
+ kind: 'task',
52
+ action: 'one',
53
+ id: taskId,
54
+ }),
49
55
  }),
50
56
  ]);
51
57
  onSuccess?.();
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte';
3
+ import { SvelteMap } from 'svelte/reactivity';
3
4
  import { fly } from 'svelte/transition';
4
5
  import { useQueryClient } from '@tanstack/svelte-query';
5
6
  import {
6
7
  captureAdminContext,
7
8
  getColorTheme,
8
- getResources,
9
9
  getTheme,
10
+ parseQueryKey,
10
11
  } from '@svadmin/core';
11
12
  import { useTranslation } from '@svadmin/core/i18n';
12
13
 
@@ -29,6 +30,17 @@
29
30
  capabilities: string;
30
31
  };
31
32
 
33
+ type SafeQueryDiagnostic = {
34
+ provider: string;
35
+ resource: string;
36
+ operation: string;
37
+ status: string;
38
+ retries: number;
39
+ duration: string;
40
+ cacheAge: string;
41
+ invalidation: string;
42
+ };
43
+
32
44
  const EMPTY_CACHE_DIAGNOSTICS: CacheDiagnostics = {
33
45
  queries: { total: 0, fetching: 0, stale: 0, errors: 0 },
34
46
  mutations: { total: 0, pending: 0, paused: 0, errors: 0 },
@@ -43,6 +55,8 @@
43
55
  let visible = $state(false);
44
56
  let collapsed = $state(false);
45
57
  let cacheDiagnostics = $state.raw<CacheDiagnostics>(EMPTY_CACHE_DIAGNOSTICS);
58
+ let safeQueryDiagnostics = $state.raw<SafeQueryDiagnostic[]>([]);
59
+ const queryTimings = new SvelteMap<string, { startedAt?: number; duration?: number }>();
46
60
 
47
61
  function toggle() {
48
62
  visible = !visible;
@@ -58,6 +72,7 @@
58
72
  function refreshCacheDiagnostics() {
59
73
  const queries = queryClient.getQueryCache().getAll();
60
74
  const mutations = queryClient.getMutationCache().getAll();
75
+ const now = Date.now();
61
76
  cacheDiagnostics = {
62
77
  queries: {
63
78
  total: queries.length,
@@ -72,12 +87,47 @@
72
87
  errors: mutations.filter((mutation) => mutation.state.status === 'error').length,
73
88
  },
74
89
  };
90
+ safeQueryDiagnostics = queries.flatMap((query) => {
91
+ const descriptor = parseQueryKey(query.queryKey);
92
+ if (!descriptor) return [];
93
+ return [{
94
+ provider: descriptor.provider,
95
+ resource: descriptor.kind === 'custom' ? '[custom]' : (descriptor.resource ?? '—'),
96
+ operation: `${descriptor.kind}:${descriptor.action ?? 'call'}`,
97
+ status: query.state.fetchStatus === 'fetching' ? 'fetching' : query.state.status,
98
+ retries: query.state.fetchFailureCount,
99
+ duration: queryTimings.get(query.queryHash)?.duration === undefined
100
+ ? '—'
101
+ : `${queryTimings.get(query.queryHash)?.duration}ms`,
102
+ cacheAge: query.state.dataUpdatedAt > 0
103
+ ? `${Math.max(0, Math.floor((now - query.state.dataUpdatedAt) / 1000))}s`
104
+ : '—',
105
+ invalidation: query.state.isInvalidated ? 'cache invalidation' : '—',
106
+ }];
107
+ });
75
108
  }
76
109
 
77
110
  onMount(() => {
78
111
  if (!isDev) return;
79
112
  refreshCacheDiagnostics();
80
- const unsubscribeQueries = queryClient.getQueryCache().subscribe(refreshCacheDiagnostics);
113
+ const unsubscribeQueries = queryClient.getQueryCache().subscribe((event) => {
114
+ // 观察器事件会在组件重渲染时成对出现。刷新诊断状态会再次触发渲染,
115
+ // 因此这里只响应真正改变缓存内容或请求状态的事件。
116
+ if (event.type.startsWith('observer')) return;
117
+ if (event.type === 'removed') {
118
+ queryTimings.delete(event.query.queryHash);
119
+ } else if (event.type === 'updated') {
120
+ const current = queryTimings.get(event.query.queryHash) ?? {};
121
+ if (event.action.type === 'fetch') {
122
+ queryTimings.set(event.query.queryHash, { startedAt: Date.now() });
123
+ } else if ((event.action.type === 'success' || event.action.type === 'error') && current.startedAt !== undefined) {
124
+ queryTimings.set(event.query.queryHash, {
125
+ duration: Math.max(0, Date.now() - current.startedAt),
126
+ });
127
+ }
128
+ }
129
+ refreshCacheDiagnostics();
130
+ });
81
131
  const unsubscribeMutations = queryClient.getMutationCache().subscribe(refreshCacheDiagnostics);
82
132
  return () => {
83
133
  unsubscribeQueries();
@@ -85,13 +135,7 @@
85
135
  };
86
136
  });
87
137
 
88
- const resources = $derived.by(() => {
89
- try {
90
- return getResources();
91
- } catch {
92
- return [];
93
- }
94
- });
138
+ const resources = $derived(adminContext.resources);
95
139
  const path = $derived(adminContext.currentPath());
96
140
  const theme = $derived(getTheme());
97
141
  const colorTheme = $derived(getColorTheme());
@@ -349,6 +393,34 @@
349
393
  </dl>
350
394
  </section>
351
395
 
396
+ <section class="space-y-1.5 sm:col-span-2">
397
+ <div class="flex items-center justify-between">
398
+ <h4 class="text-xs font-semibold text-foreground">Safe query operations</h4>
399
+ <span class="text-[0.6875rem] text-muted-foreground">IDs, tenant and params hidden</span>
400
+ </div>
401
+ {#if safeQueryDiagnostics.length === 0}
402
+ <div class="rounded-md border px-2.5 py-2 text-[0.6875rem] text-muted-foreground">
403
+ No Query Key v2 operations in cache.
404
+ </div>
405
+ {:else}
406
+ {#each safeQueryDiagnostics as query, index (`${query.provider}:${query.resource}:${query.operation}:${index}`)}
407
+ <div class="rounded-md border px-2.5 py-2" data-testid="devtools-query-operation">
408
+ <div class="flex flex-wrap items-center justify-between gap-2 text-xs">
409
+ <span class="font-medium text-foreground">{query.provider} · {query.resource}</span>
410
+ <Badge variant="outline">{query.operation}</Badge>
411
+ </div>
412
+ <dl class="mt-1 grid grid-cols-5 gap-2 text-[0.6875rem] text-muted-foreground">
413
+ <div><dt>Status</dt><dd class="text-foreground">{query.status}</dd></div>
414
+ <div><dt>Retries</dt><dd class="text-foreground">{query.retries}</dd></div>
415
+ <div><dt>Duration</dt><dd class="text-foreground">{query.duration}</dd></div>
416
+ <div><dt>Cache age</dt><dd class="text-foreground">{query.cacheAge}</dd></div>
417
+ <div><dt>Invalidation</dt><dd class="text-foreground">{query.invalidation}</dd></div>
418
+ </dl>
419
+ </div>
420
+ {/each}
421
+ {/if}
422
+ </section>
423
+
352
424
  <p class="text-[0.6875rem] text-muted-foreground sm:col-span-2">
353
425
  Query keys, variables, cached records, mutation payloads, and error bodies are intentionally hidden.
354
426
  </p>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { captureAdminContext } from '@svadmin/core';
2
+ import { captureAdminContext, queryKeyMatches } from '@svadmin/core';
3
3
  import { useQueryClient } from '@tanstack/svelte-query';
4
4
  import { useTranslation } from '@svadmin/core/i18n';
5
5
 
@@ -39,13 +39,19 @@
39
39
  await taskProvider.retry(taskId);
40
40
  await Promise.all([
41
41
  queryClient.invalidateQueries({
42
- predicate: (query) => query.queryKey[0] === 'taskList'
43
- && adminContext.matchesTenantQuery(query.queryKey),
42
+ predicate: (query) => queryKeyMatches(query.queryKey, {
43
+ ...adminContext.queryKeyMatcher(),
44
+ kind: 'task',
45
+ action: 'list',
46
+ }),
44
47
  }),
45
48
  queryClient.invalidateQueries({
46
- predicate: (query) => query.queryKey[0] === 'task'
47
- && query.queryKey[1] === taskId
48
- && adminContext.matchesTenantQuery(query.queryKey),
49
+ predicate: (query) => queryKeyMatches(query.queryKey, {
50
+ ...adminContext.queryKeyMatcher(),
51
+ kind: 'task',
52
+ action: 'one',
53
+ id: taskId,
54
+ }),
49
55
  }),
50
56
  ]);
51
57
  onSuccess?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
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.35.0",
42
+ "@svadmin/core": "^0.36.0",
43
43
  "@tanstack/svelte-query": "^6.1.38",
44
44
  "highlight.js": "^11.11.1",
45
45
  "isomorphic-dompurify": "^3.22.0",