@svadmin/ui 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Pre-built admin UI components — AdminApp, AutoTable, AutoForm, Sidebar, Layout",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -26,7 +26,25 @@
26
26
  "bits-ui": "^2.0.0",
27
27
  "tailwind-variants": "^3.0.0",
28
28
  "clsx": "^2.0.0",
29
- "tailwind-merge": "^3.0.0"
29
+ "tailwind-merge": "^3.0.0",
30
+ "marked": "^15.0.0",
31
+ "marked-highlight": "^2.2.0",
32
+ "highlight.js": "^11.0.0",
33
+ "isomorphic-dompurify": "^2.0.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "marked": {
37
+ "optional": true
38
+ },
39
+ "marked-highlight": {
40
+ "optional": true
41
+ },
42
+ "highlight.js": {
43
+ "optional": true
44
+ },
45
+ "isomorphic-dompurify": {
46
+ "optional": true
47
+ }
30
48
  },
31
49
  "dependencies": {
32
50
  "@lucide/svelte": "^0.577.0",
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ import { t } from '@svadmin/core/i18n';
3
+ import { getAuthProvider } from '@svadmin/core';
4
+ import type { AuditLog } from '@svadmin/core';
5
+ import { toast } from '@svadmin/core/toast';
6
+ import * as Table from './ui/table/index.js';
7
+ import { Badge } from './ui/badge/index.js';
8
+ import { Button } from './ui/button/index.js';
9
+ import { Input } from './ui/input/index.js';
10
+ import { Loader2, ChevronLeft, ChevronRight, Search, FileSearch, Eye, X } from 'lucide-svelte';
11
+
12
+ const authProvider = getAuthProvider();
13
+
14
+ let logs = $state<AuditLog[]>([]);
15
+ let total = $state(0);
16
+ let page = $state(1);
17
+ let pageSize = 20;
18
+ let loading = $state(true);
19
+ let error = $state<string | null>(null);
20
+ let searchQuery = $state('');
21
+
22
+ // Snapshot drawer
23
+ let drawerOpen = $state(false);
24
+ let drawerLog = $state<AuditLog | null>(null);
25
+
26
+ async function loadLogs() {
27
+ if (!authProvider?.getAuditLogs) {
28
+ error = t('settings.auditNotSupported') ?? 'Audit logs are not supported by the current AuthProvider';
29
+ loading = false;
30
+ return;
31
+ }
32
+ loading = true;
33
+ try {
34
+ const result = await authProvider.getAuditLogs({ page, pageSize });
35
+ logs = result.data;
36
+ total = result.total;
37
+ } catch (e) {
38
+ toast.error((e as Error).message);
39
+ } finally {
40
+ loading = false;
41
+ }
42
+ }
43
+
44
+ $effect(() => {
45
+ loadLogs();
46
+ });
47
+
48
+ let totalPages = $derived(Math.ceil(total / pageSize) || 1);
49
+
50
+ let filteredLogs = $derived(
51
+ searchQuery.trim()
52
+ ? logs.filter(l =>
53
+ (l.userName ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
54
+ l.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
55
+ (l.resource ?? '').toLowerCase().includes(searchQuery.toLowerCase())
56
+ )
57
+ : logs
58
+ );
59
+
60
+ function openSnapshot(log: AuditLog) {
61
+ drawerLog = log;
62
+ drawerOpen = true;
63
+ }
64
+
65
+ function formatDate(d: string | Date) {
66
+ return new Date(d).toLocaleString();
67
+ }
68
+
69
+ function getActionColor(action: string): 'default' | 'secondary' | 'destructive' | 'outline' {
70
+ const a = action.toLowerCase();
71
+ if (a.includes('delete') || a.includes('remove')) return 'destructive';
72
+ if (a.includes('create') || a.includes('add')) return 'default';
73
+ if (a.includes('update') || a.includes('edit')) return 'secondary';
74
+ return 'outline';
75
+ }
76
+ </script>
77
+
78
+ <div class="h-full flex flex-col">
79
+ <!-- Header -->
80
+ <div class="mb-6">
81
+ <h2 class="text-xl font-semibold text-foreground flex items-center gap-2">
82
+ <FileSearch class="h-5 w-5 text-muted-foreground" />
83
+ {t('settings.auditLogs') ?? 'Audit Logs'}
84
+ </h2>
85
+ <p class="text-sm text-muted-foreground mt-1">
86
+ {t('settings.auditDescription') ?? 'Track and review all system operations for compliance and debugging.'}
87
+ </p>
88
+ </div>
89
+
90
+ {#if error}
91
+ <div class="p-6 border border-destructive/20 bg-destructive/5 text-destructive rounded-lg text-center">
92
+ <p>{error}</p>
93
+ </div>
94
+ {:else}
95
+ <!-- Search Bar -->
96
+ <div class="mb-4 relative">
97
+ <Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
98
+ <Input
99
+ bind:value={searchQuery}
100
+ placeholder={t('settings.searchLogs') ?? 'Search by user, action, or resource...'}
101
+ class="pl-9"
102
+ />
103
+ </div>
104
+
105
+ <!-- Table -->
106
+ <div class="flex-1 border rounded-lg bg-card overflow-hidden">
107
+ {#if loading}
108
+ <div class="flex items-center justify-center h-48 text-muted-foreground">
109
+ <Loader2 class="h-5 w-5 animate-spin mr-2" />
110
+ {t('common.loading')}
111
+ </div>
112
+ {:else}
113
+ <div class="overflow-auto">
114
+ <Table.Root>
115
+ <Table.Header>
116
+ <Table.Row>
117
+ <Table.Head class="w-[160px]">{t('settings.auditTime') ?? 'Time'}</Table.Head>
118
+ <Table.Head>{t('settings.auditUser') ?? 'User'}</Table.Head>
119
+ <Table.Head>{t('settings.auditAction') ?? 'Action'}</Table.Head>
120
+ <Table.Head>{t('settings.auditResource') ?? 'Resource'}</Table.Head>
121
+ <Table.Head class="w-[120px]">{t('settings.auditIp') ?? 'IP Address'}</Table.Head>
122
+ <Table.Head class="w-[60px] text-center">{t('common.detail') ?? 'Detail'}</Table.Head>
123
+ </Table.Row>
124
+ </Table.Header>
125
+ <Table.Body>
126
+ {#each filteredLogs as log}
127
+ <Table.Row class="hover:bg-muted/50 transition-colors">
128
+ <Table.Cell class="text-xs text-muted-foreground font-mono">
129
+ {formatDate(log.createdAt)}
130
+ </Table.Cell>
131
+ <Table.Cell class="text-sm font-medium">{log.userName ?? log.userId ?? '—'}</Table.Cell>
132
+ <Table.Cell>
133
+ <Badge variant={getActionColor(log.action)}>{log.action}</Badge>
134
+ </Table.Cell>
135
+ <Table.Cell class="text-sm text-muted-foreground">{log.resource ?? '—'}</Table.Cell>
136
+ <Table.Cell class="text-xs text-muted-foreground font-mono">{log.ipAddress ?? '—'}</Table.Cell>
137
+ <Table.Cell class="text-center">
138
+ {#if log.details}
139
+ <Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => openSnapshot(log)}>
140
+ <Eye class="h-3.5 w-3.5" />
141
+ </Button>
142
+ {/if}
143
+ </Table.Cell>
144
+ </Table.Row>
145
+ {:else}
146
+ <Table.Row>
147
+ <Table.Cell colspan={6} class="h-24 text-center text-muted-foreground">
148
+ {t('common.noData')}
149
+ </Table.Cell>
150
+ </Table.Row>
151
+ {/each}
152
+ </Table.Body>
153
+ </Table.Root>
154
+ </div>
155
+ {/if}
156
+ </div>
157
+
158
+ <!-- Pagination -->
159
+ <div class="mt-4 flex items-center justify-between text-sm text-muted-foreground">
160
+ <span>{total} {t('settings.auditTotal') ?? 'total records'}</span>
161
+ <div class="flex items-center gap-2">
162
+ <Button
163
+ variant="outline" size="sm"
164
+ disabled={page <= 1}
165
+ onclick={() => { page--; loadLogs(); }}
166
+ >
167
+ <ChevronLeft class="h-4 w-4" />
168
+ </Button>
169
+ <span>{page} / {totalPages}</span>
170
+ <Button
171
+ variant="outline" size="sm"
172
+ disabled={page >= totalPages}
173
+ onclick={() => { page++; loadLogs(); }}
174
+ >
175
+ <ChevronRight class="h-4 w-4" />
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ {/if}
180
+ </div>
181
+
182
+ <!-- Snapshot Drawer -->
183
+ {#if drawerOpen && drawerLog}
184
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
185
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
186
+ <div class="fixed inset-0 z-[100] flex justify-end bg-black/40 backdrop-blur-sm" onclick={() => drawerOpen = false}>
187
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
188
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
189
+ <div class="bg-card shadow-2xl w-full max-w-2xl h-full flex flex-col animate-in slide-in-from-right duration-300" onclick={(e) => e.stopPropagation()}>
190
+ <!-- Drawer Header -->
191
+ <div class="p-6 border-b border-border flex items-center justify-between bg-muted/30">
192
+ <div>
193
+ <h2 class="text-lg font-bold text-foreground flex items-center gap-2">
194
+ <FileSearch class="h-5 w-5 text-primary" />
195
+ {t('settings.auditSnapshot') ?? 'Change Snapshot'}
196
+ </h2>
197
+ <p class="text-xs text-muted-foreground mt-1">{formatDate(drawerLog.createdAt)} · {drawerLog.userName ?? drawerLog.userId}</p>
198
+ </div>
199
+ <button onclick={() => drawerOpen = false} class="text-muted-foreground hover:text-foreground transition p-2 rounded-lg hover:bg-muted">
200
+ <X class="w-5 h-5" />
201
+ </button>
202
+ </div>
203
+
204
+ <!-- Meta Tags -->
205
+ <div class="px-6 py-3 border-b border-border flex items-center gap-2 flex-wrap">
206
+ <Badge variant="outline">{drawerLog.action}</Badge>
207
+ {#if drawerLog.resource}
208
+ <Badge variant="secondary">{drawerLog.resource}</Badge>
209
+ {/if}
210
+ {#if drawerLog.ipAddress}
211
+ <Badge variant="outline" class="font-mono text-xs">{drawerLog.ipAddress}</Badge>
212
+ {/if}
213
+ </div>
214
+
215
+ <!-- JSON Diff Viewer -->
216
+ <div class="flex-1 p-6 overflow-y-auto bg-foreground/[0.03] dark:bg-background/50">
217
+ <pre class="whitespace-pre-wrap text-xs font-mono leading-relaxed text-foreground/80 bg-muted/30 rounded-lg p-4 border border-border">{typeof drawerLog.details === 'object' ? JSON.stringify(drawerLog.details, null, 2) : drawerLog.details}</pre>
218
+ </div>
219
+
220
+ <!-- Footer -->
221
+ <div class="p-4 border-t border-border bg-muted/20 text-right text-[11px] text-muted-foreground font-medium">
222
+ System audit snapshot · Read-only
223
+ </div>
224
+ </div>
225
+ </div>
226
+ {/if}
@@ -13,7 +13,7 @@
13
13
  import { useList, useDelete, getResource } from '@svadmin/core';
14
14
  import type { Pagination as PaginationState, Sort, Filter, FieldDefinition } from '@svadmin/core';
15
15
  import { navigate } from '@svadmin/core/router';
16
- import { canAccess } from '@svadmin/core/permissions';
16
+ import { useCan, getAccessControlProvider } from '@svadmin/core';
17
17
  import { readURLState, writeURLState } from '@svadmin/core';
18
18
  import { t } from '@svadmin/core/i18n';
19
19
  import { fade } from 'svelte/transition';
@@ -136,10 +136,15 @@
136
136
  const deleteMutation = deleteResult.mutation;
137
137
 
138
138
  // ─── Permissions ──────────────────────────────────────────────
139
- const canCreate = $derived(canAccess(resourceName, 'create').can && resource.canCreate !== false);
140
- const canEdit = $derived(canAccess(resourceName, 'edit').can && resource.canEdit !== false);
141
- const canDelete = $derived(canAccess(resourceName, 'delete').can && resource.canDelete !== false);
142
- const canExport = $derived(canAccess(resourceName, 'export').can);
139
+ const acEnabled = $derived(!!getAccessControlProvider());
140
+ const canCreatePerm = useCan(() => ({ resource: resourceName, action: 'create', queryOptions: { enabled: acEnabled } }));
141
+ const canEditPerm = useCan(() => ({ resource: resourceName, action: 'edit', queryOptions: { enabled: acEnabled } }));
142
+ const canDeletePerm = useCan(() => ({ resource: resourceName, action: 'delete', queryOptions: { enabled: acEnabled } }));
143
+ const canExportPerm = useCan(() => ({ resource: resourceName, action: 'export', queryOptions: { enabled: acEnabled } }));
144
+ const canCreate = $derived(canCreatePerm.allowed && resource.canCreate !== false);
145
+ const canEdit = $derived(canEditPerm.allowed && resource.canEdit !== false);
146
+ const canDelete = $derived(canDeletePerm.allowed && resource.canDelete !== false);
147
+ const canExport = $derived(canExportPerm.allowed);
143
148
 
144
149
  // ─── TanStack Table state ────────────────────────────────────
145
150
  let sorting = $state<SortingState>(untrack(() =>
@@ -11,7 +11,7 @@
11
11
  fallback?: Snippet;
12
12
  }>();
13
13
 
14
- const can = $derived(useCan(resource, action, params));
14
+ const can = useCan(() => ({ resource, action, params }));
15
15
  </script>
16
16
 
17
17
  {#if can.isLoading}
@@ -0,0 +1,90 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export interface GridModule {
5
+ id: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ let {
10
+ modules = $bindable<GridModule[]>([]),
11
+ onOrderSave,
12
+ renderItem,
13
+ class: className = '',
14
+ columns = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
15
+ gap = 'gap-4',
16
+ }: {
17
+ modules?: GridModule[];
18
+ onOrderSave?: (orderedIds: string[]) => void;
19
+ renderItem: Snippet<[{ module: GridModule; index: number; dragging: boolean }]>;
20
+ class?: string;
21
+ columns?: string;
22
+ gap?: string;
23
+ } = $props();
24
+
25
+ let draggingId = $state<string | null>(null);
26
+ let dragOverId = $state<string | null>(null);
27
+
28
+ function handleDragStart(e: DragEvent, id: string) {
29
+ draggingId = id;
30
+ if (e.dataTransfer) {
31
+ e.dataTransfer.effectAllowed = 'move';
32
+ e.dataTransfer.setData('text/plain', id);
33
+ }
34
+ }
35
+
36
+ function handleDragOver(e: DragEvent, id: string) {
37
+ e.preventDefault();
38
+ if (draggingId && draggingId !== id) {
39
+ dragOverId = id;
40
+ }
41
+ }
42
+
43
+ function handleDrop(e: DragEvent, targetId: string) {
44
+ e.preventDefault();
45
+ if (!draggingId || draggingId === targetId) {
46
+ dragOverId = null;
47
+ return;
48
+ }
49
+
50
+ const sourceIdx = modules.findIndex(m => m.id === draggingId);
51
+ const targetIdx = modules.findIndex(m => m.id === targetId);
52
+
53
+ if (sourceIdx === -1 || targetIdx === -1) {
54
+ dragOverId = null;
55
+ return;
56
+ }
57
+
58
+ const newModules = [...modules];
59
+ const [dragged] = newModules.splice(sourceIdx, 1);
60
+ newModules.splice(targetIdx, 0, dragged);
61
+
62
+ modules = newModules;
63
+ dragOverId = null;
64
+ onOrderSave?.(modules.map(m => m.id));
65
+ }
66
+
67
+ function handleDragEnd() {
68
+ draggingId = null;
69
+ dragOverId = null;
70
+ }
71
+ </script>
72
+
73
+ <div class="grid {columns} {gap} {className}">
74
+ {#each modules as mod, i (mod.id)}
75
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
76
+ <div
77
+ draggable="true"
78
+ ondragstart={(e) => handleDragStart(e, mod.id)}
79
+ ondragover={(e) => handleDragOver(e, mod.id)}
80
+ ondrop={(e) => handleDrop(e, mod.id)}
81
+ ondragend={handleDragEnd}
82
+ ondragleave={() => { if (dragOverId === mod.id) dragOverId = null; }}
83
+ class="transition-all duration-200
84
+ {draggingId === mod.id ? 'opacity-40 scale-[0.97] cursor-grabbing' : 'cursor-grab'}
85
+ {dragOverId === mod.id ? 'ring-2 ring-primary/40 ring-offset-2 ring-offset-background rounded-lg scale-[1.02]' : ''}"
86
+ >
87
+ {@render renderItem({ module: mod, index: i, dragging: draggingId === mod.id })}
88
+ </div>
89
+ {/each}
90
+ </div>
@@ -15,6 +15,7 @@
15
15
  import { Button } from './ui/button/index.js';
16
16
  import TooltipButton from './TooltipButton.svelte';
17
17
  import ComboboxField from './ComboboxField.svelte';
18
+ import ArrayField from './fields/ArrayField.svelte';
18
19
  import { Plus, X } from 'lucide-svelte';
19
20
  import type { Snippet } from 'svelte';
20
21
 
@@ -86,6 +87,9 @@
86
87
  {#if children}
87
88
  {@render children()}
88
89
 
90
+ {:else if field.type === 'array'}
91
+ <ArrayField {field} {value} {onchange} />
92
+
89
93
  {:else if field.type === 'text' || field.type === 'image'}
90
94
  <Input
91
95
  id={field.key}
@@ -1,13 +1,9 @@
1
1
  <script lang="ts">
2
- // @ts-expect-error - missing types
3
2
  import { Marked } from "marked";
4
- // @ts-expect-error - missing types
5
3
  import { markedHighlight } from "marked-highlight";
6
- // @ts-expect-error - missing types
7
4
  import * as hljsModule from "highlight.js";
8
5
  const hljs = "default" in hljsModule ? hljsModule.default : hljsModule;
9
6
  import "highlight.js/styles/github-dark.css"; // or your preferred theme
10
- // @ts-expect-error - missing types
11
7
  import DOMPurify from "isomorphic-dompurify";
12
8
  import { Check, Copy } from "lucide-svelte";
13
9
  import { Button } from "./ui/button/index.js";
@@ -0,0 +1,190 @@
1
+ <script module context="module">
2
+ export type RoleInfo = { code: string; name: string; [key: string]: any };
3
+ export type ResourceInfo = { code: string; name: string; section?: string; [key: string]: any };
4
+ export type ActionInfo = { code: string; name: string; [key: string]: any };
5
+ </script>
6
+
7
+ <script lang="ts">
8
+ import { Shield, AlertCircle, Check } from 'lucide-svelte';
9
+ import type { Snippet } from 'svelte';
10
+
11
+ let {
12
+ // Basic metadata lists
13
+ roles = [],
14
+ resources = [],
15
+ actions = [],
16
+
17
+ // Authorization state resolver
18
+ // isGranted(roleCode, resourceCode, actionCode) => boolean
19
+ isGranted,
20
+
21
+ // Interactive states
22
+ selectedRole = $bindable(roles[0]?.code || ''),
23
+ loading = false,
24
+
25
+ // Feedback and alerts (passed down from upper business logic)
26
+ message = '',
27
+ messageType = 'success', // 'success' | 'error' | 'warning'
28
+ conflictCount = 0,
29
+
30
+ // Custom slots for injecting multi-tenant switchers or security filters
31
+ sidebarExtra,
32
+ headerExtra,
33
+
34
+ // Callback event
35
+ onToggle
36
+ }: {
37
+ roles: RoleInfo[];
38
+ resources: ResourceInfo[];
39
+ actions: ActionInfo[];
40
+ isGranted: (role: string, resource: string, action: string) => boolean;
41
+ selectedRole?: string;
42
+ loading?: boolean;
43
+ message?: string;
44
+ messageType?: 'success' | 'error' | 'warning';
45
+ conflictCount?: number;
46
+ sidebarExtra?: Snippet;
47
+ headerExtra?: Snippet;
48
+ onToggle: (role: string, resource: string, action: string, grant: boolean) => void;
49
+ } = $props();
50
+
51
+ </script>
52
+
53
+ <div class="h-full flex flex-col pt-2">
54
+ <!-- Header Title -->
55
+ <div class="mb-6 flex items-center justify-between">
56
+ <div>
57
+ <h1 class="text-2xl font-bold text-foreground flex items-center gap-2">
58
+ <Shield class="w-6 h-6 text-primary" />
59
+ 统一操作权限矩阵
60
+ </h1>
61
+ <p class="text-muted-foreground mt-1 text-sm">直观配置系统模块与角色的鉴权规则网络。</p>
62
+ </div>
63
+
64
+ <!-- Toast Feedback -->
65
+ {#if message}
66
+ <div class={`px-4 py-2 rounded-lg text-sm font-medium shadow-sm transition-all flex items-center gap-2 ${messageType === 'success' ? 'bg-green-500/10 text-green-600 dark:text-green-400 ring-1 ring-green-500/20' : 'bg-destructive/10 text-destructive ring-1 ring-destructive/20'}`}>
67
+ {#if messageType === 'success'}
68
+ <Check class="w-4 h-4" />
69
+ {:else}
70
+ <AlertCircle class="w-4 h-4" />
71
+ {/if}
72
+ {message}
73
+ </div>
74
+ {/if}
75
+ </div>
76
+
77
+ <div class="flex flex-col md:flex-row gap-6 flex-1 min-h-[500px]">
78
+ <!-- Left: Role Selector Sidebar -->
79
+ <div class="w-full md:w-64 flex-shrink-0 bg-card rounded-xl shadow-sm border border-border p-4 flex flex-col">
80
+ <h2 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-2">业务受众角色</h2>
81
+ <div class="space-y-1 flex-1 overflow-y-auto">
82
+ {#each roles as role}
83
+ <button
84
+ onclick={() => selectedRole = role.code}
85
+ class="w-full text-left px-3 py-2.5 rounded-lg text-sm transition-all duration-200 {selectedRole === role.code ? 'bg-primary/10 text-primary font-medium shadow-sm ring-1 ring-primary/20' : 'text-foreground hover:bg-muted'}"
86
+ >
87
+ {role.name}
88
+ <div class="text-[10px] mt-0.5 opacity-60 font-mono tracking-tighter">{role.code}</div>
89
+ </button>
90
+ {/each}
91
+ {#if roles.length === 0}
92
+ <p class="text-center text-muted-foreground text-sm p-4">暂无角色</p>
93
+ {/if}
94
+ </div>
95
+
96
+ <!-- Slot for Upper Logic inject (e.g. Org Switcher, Security Level Filter) -->
97
+ {#if sidebarExtra}
98
+ <div class="mt-4 pt-4 border-t border-border">
99
+ {@render sidebarExtra()}
100
+ </div>
101
+ {/if}
102
+ </div>
103
+
104
+ <!-- Right: Matrix Grid -->
105
+ <div class="flex-1 bg-card rounded-xl shadow-sm border border-border overflow-hidden flex flex-col">
106
+ <!-- Matrix Header -->
107
+ <div class="p-5 border-b border-border bg-muted/30 flex items-center justify-between">
108
+ <div class="flex items-center gap-4">
109
+ <div>
110
+ <h2 class="text-lg font-bold text-foreground">当前视角:
111
+ <span class="text-primary">{roles.find(r => r.code === selectedRole)?.name || '未选择'}</span>
112
+ </h2>
113
+ <p class="text-xs text-muted-foreground mt-1">勾选以即时挂载/收回操作权限</p>
114
+ </div>
115
+
116
+ <!-- Slot for header extra logic -->
117
+ {#if headerExtra}
118
+ {@render headerExtra()}
119
+ {/if}
120
+ </div>
121
+
122
+ {#if conflictCount > 0}
123
+ <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">
124
+ <AlertCircle class="w-4 h-4" /> {conflictCount} 重叠/冲突规则
125
+ </div>
126
+ {/if}
127
+ </div>
128
+
129
+ <!-- Table Area -->
130
+ <div class="flex-1 overflow-auto p-4">
131
+ <table class="min-w-full divide-y divide-border border border-border rounded-lg overflow-hidden">
132
+ <thead class="bg-muted/50">
133
+ <tr>
134
+ <th scope="col" class="py-3 pr-3 pl-4 text-left text-sm font-semibold text-foreground border-r border-border w-48">模块域与资源点</th>
135
+ {#each actions as action}
136
+ <th scope="col" class="px-2 py-3 text-center text-sm font-semibold text-foreground border-r border-border">
137
+ <div class="flex flex-col items-center">
138
+ <span>{action.name}</span>
139
+ <span class="text-[10px] text-muted-foreground font-mono">{action.code}</span>
140
+ </div>
141
+ </th>
142
+ {/each}
143
+ </tr>
144
+ </thead>
145
+ <tbody class="divide-y divide-border bg-card">
146
+ {#if resources.length === 0}
147
+ <tr>
148
+ <td colspan={actions.length + 1} class="py-10 text-center text-muted-foreground">暂无可用资源</td>
149
+ </tr>
150
+ {/if}
151
+ {#each resources as resource, i}
152
+ <!-- Section Grouping Header -->
153
+ {#if resource.section && (i === 0 || resource.section !== resources[i-1].section)}
154
+ <tr class="bg-primary/5">
155
+ <td colspan="{actions.length + 1}" class="px-4 py-2 text-xs font-bold text-primary uppercase tracking-wider">{resource.section}</td>
156
+ </tr>
157
+ {/if}
158
+
159
+ <tr class="hover:bg-muted/50 transition-colors">
160
+ <td class="whitespace-nowrap py-3 pl-4 pr-3 text-sm border-r border-border">
161
+ <div class="font-medium text-foreground">{resource.name}</div>
162
+ <div class="text-xs text-muted-foreground font-mono">{resource.code}</div>
163
+ </td>
164
+
165
+ <!-- Action Checkboxes -->
166
+ {#each actions as action}
167
+ {@const granted = selectedRole ? isGranted(selectedRole, resource.code, action.code) : false}
168
+ <td class="whitespace-nowrap px-3 py-3 text-center border-r border-border">
169
+ <div class="flex items-center justify-center">
170
+ <button
171
+ disabled={loading || !selectedRole}
172
+ aria-label="Toggle {action.name}"
173
+ onclick={() => onToggle(selectedRole!, resource.code, action.code, !granted)}
174
+ class="w-6 h-6 rounded border flex items-center justify-center transition-all focus:outline-none focus:ring-2 focus:ring-primary/50 {granted ? 'bg-primary border-primary text-primary-foreground' : 'border-input bg-background hover:bg-accent'} {loading || !selectedRole ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
175
+ >
176
+ <svg class="w-3.5 h-3.5 {granted ? 'block' : 'hidden'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
177
+ <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
178
+ </svg>
179
+ </button>
180
+ </div>
181
+ </td>
182
+ {/each}
183
+ </tr>
184
+ {/each}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>