@svadmin/ui 0.18.0 → 0.20.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 +24 -2
- package/src/components/AuditLogViewer.svelte +226 -0
- package/src/components/AutoTable.svelte +11 -5
- package/src/components/CanAccess.svelte +1 -1
- package/src/components/ChatDialog.svelte +104 -20
- package/src/components/CommandPalette.svelte +47 -5
- package/src/components/DraggableGrid.svelte +90 -0
- package/src/components/FieldRenderer.svelte +36 -2
- package/src/components/MarkdownRenderer.svelte +0 -4
- package/src/components/PermissionMatrix.svelte +190 -0
- package/src/components/RolesSettings.svelte +144 -0
- package/src/components/SettingsPage.svelte +12 -2
- package/src/components/TaskQueueDrawer.svelte +170 -0
- package/src/components/TenantSwitcher.svelte +90 -0
- package/src/components/buttons/CloneButton.svelte +3 -3
- package/src/components/buttons/CreateButton.svelte +3 -3
- package/src/components/buttons/DeleteButton.svelte +3 -3
- package/src/components/buttons/EditButton.svelte +3 -3
- package/src/components/buttons/ExportButton.svelte +3 -3
- package/src/components/buttons/ImportButton.svelte +3 -3
- package/src/components/buttons/ShowButton.svelte +3 -3
- package/src/components/fields/ArrayField.svelte +86 -0
- package/src/index.ts +14 -0
- package/src/markdown-deps.d.ts +21 -0
- package/src/components/stories/BarChart.stories.ts +0 -43
- package/src/components/stories/LineChart.stories.ts +0 -48
- package/src/components/stories/PieChart.stories.ts +0 -41
|
@@ -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}
|
|
@@ -159,13 +163,43 @@
|
|
|
159
163
|
required={field.required}
|
|
160
164
|
/>
|
|
161
165
|
|
|
162
|
-
{:else if field.type === '
|
|
166
|
+
{:else if field.type === 'richtext'}
|
|
167
|
+
{#await import('@svadmin/editor')}
|
|
168
|
+
<Textarea
|
|
169
|
+
id={field.key}
|
|
170
|
+
value={strVal}
|
|
171
|
+
oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
|
|
172
|
+
required={field.required}
|
|
173
|
+
rows={10}
|
|
174
|
+
placeholder={t('field.enterValue', { label: field.label })}
|
|
175
|
+
class="resize-y"
|
|
176
|
+
/>
|
|
177
|
+
{:then editorMod}
|
|
178
|
+
<editorMod.Editor
|
|
179
|
+
value={strVal}
|
|
180
|
+
placeholder={t('field.enterValue', { label: field.label })}
|
|
181
|
+
preset="full"
|
|
182
|
+
onchange={(html) => onchange(html)}
|
|
183
|
+
/>
|
|
184
|
+
{:catch}
|
|
185
|
+
<Textarea
|
|
186
|
+
id={field.key}
|
|
187
|
+
value={strVal}
|
|
188
|
+
oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
|
|
189
|
+
required={field.required}
|
|
190
|
+
rows={10}
|
|
191
|
+
placeholder={t('field.enterValue', { label: field.label })}
|
|
192
|
+
class="resize-y"
|
|
193
|
+
/>
|
|
194
|
+
{/await}
|
|
195
|
+
|
|
196
|
+
{:else if field.type === 'textarea'}
|
|
163
197
|
<Textarea
|
|
164
198
|
id={field.key}
|
|
165
199
|
value={strVal}
|
|
166
200
|
oninput={(e) => onchange((e.target as HTMLTextAreaElement).value)}
|
|
167
201
|
required={field.required}
|
|
168
|
-
rows={
|
|
202
|
+
rows={4}
|
|
169
203
|
placeholder={t('field.enterValue', { label: field.label })}
|
|
170
204
|
class="resize-y"
|
|
171
205
|
/>
|
|
@@ -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>
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '@svadmin/core/i18n';
|
|
3
|
+
import { getAuthProvider, getResources } from '@svadmin/core';
|
|
4
|
+
import type { Role } from '@svadmin/core';
|
|
5
|
+
import PermissionMatrix, { type RoleInfo, type ResourceInfo, type ActionInfo } from './PermissionMatrix.svelte';
|
|
6
|
+
import { toast } from '@svadmin/core/toast';
|
|
7
|
+
|
|
8
|
+
const authProvider = getAuthProvider();
|
|
9
|
+
const rawResources = getResources();
|
|
10
|
+
|
|
11
|
+
let roles = $state<RoleInfo[]>([]);
|
|
12
|
+
let selectedRoleCode = $state<string>('');
|
|
13
|
+
|
|
14
|
+
// Matrix format: { "roleId": { "resourceName": ["action1", "action2"] } }
|
|
15
|
+
let permissionsCache = $state<Record<string, Record<string, string[]>>>({});
|
|
16
|
+
|
|
17
|
+
let loadingRoles = $state(true);
|
|
18
|
+
let loadingPermissions = $state(false);
|
|
19
|
+
let saving = $state(false);
|
|
20
|
+
let error = $state<string | null>(null);
|
|
21
|
+
|
|
22
|
+
// Convert system resources to MatrixResource format
|
|
23
|
+
let matrixResources = $derived<ResourceInfo[]>(
|
|
24
|
+
rawResources.map(r => ({
|
|
25
|
+
code: r.name,
|
|
26
|
+
name: r.label || r.name,
|
|
27
|
+
}))
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
let matrixActions = $state<ActionInfo[]>([
|
|
31
|
+
{ code: 'create', name: t('common.create') ?? 'Create' },
|
|
32
|
+
{ code: 'read', name: t('common.detail') ?? 'Read' },
|
|
33
|
+
{ code: 'update', name: t('common.edit') ?? 'Update' },
|
|
34
|
+
{ code: 'delete', name: t('common.delete') ?? 'Delete' },
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
async function loadRoles() {
|
|
38
|
+
if (!authProvider?.getRoles) {
|
|
39
|
+
error = t('settings.rbacNotSupported') ?? 'RBAC not supported by AuthProvider';
|
|
40
|
+
loadingRoles = false;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const fetchedRoles = await authProvider.getRoles();
|
|
45
|
+
roles = fetchedRoles.map(r => ({ code: r.id, name: r.name, ...r }));
|
|
46
|
+
if (roles.length > 0 && !selectedRoleCode) {
|
|
47
|
+
selectedRoleCode = roles[0].code;
|
|
48
|
+
}
|
|
49
|
+
} catch (e) {
|
|
50
|
+
error = (e as Error).message;
|
|
51
|
+
} finally {
|
|
52
|
+
loadingRoles = false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadPermissionsForRole(roleId: string) {
|
|
57
|
+
if (!authProvider?.getRolePermissions || permissionsCache[roleId]) return;
|
|
58
|
+
|
|
59
|
+
loadingPermissions = true;
|
|
60
|
+
try {
|
|
61
|
+
const perms = await authProvider.getRolePermissions(roleId);
|
|
62
|
+
permissionsCache[roleId] = perms;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
toast.error((e as Error).message);
|
|
65
|
+
} finally {
|
|
66
|
+
loadingPermissions = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Load permissions whenever the selected role changes
|
|
71
|
+
$effect(() => {
|
|
72
|
+
if (selectedRoleCode && !permissionsCache[selectedRoleCode] && !loadingPermissions) {
|
|
73
|
+
loadPermissionsForRole(selectedRoleCode);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
$effect(() => {
|
|
78
|
+
loadRoles();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// isGranted logic
|
|
82
|
+
function checkGrant(roleCode: string, resourceCode: string, actionCode: string) {
|
|
83
|
+
const rolePerms = permissionsCache[roleCode];
|
|
84
|
+
if (!rolePerms) return false;
|
|
85
|
+
return rolePerms[resourceCode]?.includes(actionCode) ?? false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleToggle(roleCode: string, resourceCode: string, actionCode: string, grant: boolean) {
|
|
89
|
+
if (!authProvider?.updateRolePermissions) return;
|
|
90
|
+
|
|
91
|
+
// Copy state
|
|
92
|
+
const currentRolePerms = { ...(permissionsCache[roleCode] || {}) };
|
|
93
|
+
const currentResPerms = [...(currentRolePerms[resourceCode] || [])];
|
|
94
|
+
|
|
95
|
+
// Optimistic update
|
|
96
|
+
if (grant) {
|
|
97
|
+
if (!currentResPerms.includes(actionCode)) currentResPerms.push(actionCode);
|
|
98
|
+
} else {
|
|
99
|
+
const idx = currentResPerms.indexOf(actionCode);
|
|
100
|
+
if (idx > -1) currentResPerms.splice(idx, 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
currentRolePerms[resourceCode] = currentResPerms;
|
|
104
|
+
permissionsCache[roleCode] = currentRolePerms;
|
|
105
|
+
|
|
106
|
+
saving = true;
|
|
107
|
+
try {
|
|
108
|
+
// Send the entire role's permissions back (as defined by our AuthProvider interface)
|
|
109
|
+
const result = await authProvider.updateRolePermissions(roleCode, currentRolePerms);
|
|
110
|
+
if (result.success) {
|
|
111
|
+
// Success silently
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error(result.error?.message ?? t('common.operationFailed'));
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
toast.error((e as Error).message);
|
|
117
|
+
// Rollback on fail
|
|
118
|
+
delete permissionsCache[roleCode];
|
|
119
|
+
loadPermissionsForRole(roleCode);
|
|
120
|
+
} finally {
|
|
121
|
+
saving = false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
{#if error}
|
|
127
|
+
<div class="h-full flex items-center justify-center">
|
|
128
|
+
<div class="max-w-md p-6 border border-destructive/20 bg-destructive/10 text-destructive rounded-lg flex flex-col items-center gap-3 text-center">
|
|
129
|
+
<AlertCircle class="h-8 w-8" />
|
|
130
|
+
<h3 class="font-bold text-lg">{t('common.error') ?? 'Error'}</h3>
|
|
131
|
+
<p>{error}</p>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
{:else}
|
|
135
|
+
<PermissionMatrix
|
|
136
|
+
{roles}
|
|
137
|
+
resources={matrixResources}
|
|
138
|
+
actions={matrixActions}
|
|
139
|
+
isGranted={checkGrant}
|
|
140
|
+
bind:selectedRole={selectedRoleCode}
|
|
141
|
+
loading={loadingRoles || loadingPermissions || saving}
|
|
142
|
+
onToggle={handleToggle}
|
|
143
|
+
/>
|
|
144
|
+
{/if}
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
import { t } from '@svadmin/core/i18n';
|
|
3
3
|
import { navigate } from '@svadmin/core/router';
|
|
4
4
|
import { currentPath } from '@svadmin/core/router';
|
|
5
|
-
import { User, Palette, Info } from 'lucide-svelte';
|
|
5
|
+
import { User, Palette, Info, Shield, FileSearch } from 'lucide-svelte';
|
|
6
6
|
import ProfilePage from './ProfilePage.svelte';
|
|
7
7
|
import AppearanceSettings from './AppearanceSettings.svelte';
|
|
8
8
|
import AboutSettings from './AboutSettings.svelte';
|
|
9
|
+
import RolesSettings from './RolesSettings.svelte';
|
|
10
|
+
import AuditLogViewer from './AuditLogViewer.svelte';
|
|
9
11
|
|
|
10
12
|
const sections = [
|
|
11
13
|
{
|
|
@@ -18,6 +20,8 @@
|
|
|
18
20
|
{
|
|
19
21
|
group: 'settings.system',
|
|
20
22
|
items: [
|
|
23
|
+
{ key: 'roles', path: '/settings/roles', icon: Shield, label: 'settings.rolesAndPermissions' },
|
|
24
|
+
{ key: 'audit', path: '/settings/audit', icon: FileSearch, label: 'settings.auditLogs' },
|
|
21
25
|
{ key: 'about', path: '/settings/about', icon: Info, label: 'settings.about' },
|
|
22
26
|
],
|
|
23
27
|
},
|
|
@@ -26,6 +30,8 @@
|
|
|
26
30
|
let activeKey = $derived.by(() => {
|
|
27
31
|
const path = currentPath();
|
|
28
32
|
if (path.includes('/settings/appearance')) return 'appearance';
|
|
33
|
+
if (path.includes('/settings/roles')) return 'roles';
|
|
34
|
+
if (path.includes('/settings/audit')) return 'audit';
|
|
29
35
|
if (path.includes('/settings/about')) return 'about';
|
|
30
36
|
return 'profile';
|
|
31
37
|
});
|
|
@@ -91,11 +97,15 @@
|
|
|
91
97
|
</nav>
|
|
92
98
|
|
|
93
99
|
<!-- Right content area -->
|
|
94
|
-
<div class="flex-1 p-6 lg:p-8 max-w-3xl">
|
|
100
|
+
<div class="flex-1 p-6 lg:p-8 {activeKey === 'roles' || activeKey === 'audit' ? 'max-w-6xl' : 'max-w-3xl'}">
|
|
95
101
|
{#if activeKey === 'profile'}
|
|
96
102
|
<ProfilePage />
|
|
97
103
|
{:else if activeKey === 'appearance'}
|
|
98
104
|
<AppearanceSettings />
|
|
105
|
+
{:else if activeKey === 'roles'}
|
|
106
|
+
<RolesSettings />
|
|
107
|
+
{:else if activeKey === 'audit'}
|
|
108
|
+
<AuditLogViewer />
|
|
99
109
|
{:else if activeKey === 'about'}
|
|
100
110
|
<AboutSettings />
|
|
101
111
|
{/if}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { Loader2, CheckCircle2, XCircle, Clock, Download, X, ListTodo } from 'lucide-svelte';
|
|
4
|
+
import { Button } from './ui/button/index.js';
|
|
5
|
+
import { Badge } from './ui/badge/index.js';
|
|
6
|
+
|
|
7
|
+
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
8
|
+
|
|
9
|
+
export interface BackgroundTask {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
status: TaskStatus;
|
|
13
|
+
progress?: number; // 0-100
|
|
14
|
+
message?: string;
|
|
15
|
+
createdAt: string | Date;
|
|
16
|
+
downloadUrl?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
open = $bindable(false),
|
|
22
|
+
tasks = $bindable<BackgroundTask[]>([]),
|
|
23
|
+
onDismiss,
|
|
24
|
+
onRetry,
|
|
25
|
+
onDownload,
|
|
26
|
+
}: {
|
|
27
|
+
open?: boolean;
|
|
28
|
+
tasks?: BackgroundTask[];
|
|
29
|
+
onDismiss?: (taskId: string) => void;
|
|
30
|
+
onRetry?: (taskId: string) => void;
|
|
31
|
+
onDownload?: (taskId: string, url: string) => void;
|
|
32
|
+
} = $props();
|
|
33
|
+
|
|
34
|
+
let runningCount = $derived(tasks.filter(t => t.status === 'running' || t.status === 'pending').length);
|
|
35
|
+
|
|
36
|
+
const statusIcon: Record<TaskStatus, typeof Loader2> = {
|
|
37
|
+
pending: Clock,
|
|
38
|
+
running: Loader2,
|
|
39
|
+
completed: CheckCircle2,
|
|
40
|
+
failed: XCircle,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const statusColor: Record<TaskStatus, string> = {
|
|
44
|
+
pending: 'text-muted-foreground',
|
|
45
|
+
running: 'text-primary',
|
|
46
|
+
completed: 'text-green-500',
|
|
47
|
+
failed: 'text-destructive',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function formatTime(d: string | Date) {
|
|
51
|
+
return new Date(d).toLocaleTimeString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dismissTask(id: string) {
|
|
55
|
+
tasks = tasks.filter(t => t.id !== id);
|
|
56
|
+
onDismiss?.(id);
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<!-- Trigger Button (to be placed in Header) -->
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
size="icon"
|
|
64
|
+
class="relative"
|
|
65
|
+
onclick={() => open = !open}
|
|
66
|
+
>
|
|
67
|
+
<ListTodo class="h-4 w-4" />
|
|
68
|
+
{#if runningCount > 0}
|
|
69
|
+
<span class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
|
70
|
+
{runningCount}
|
|
71
|
+
</span>
|
|
72
|
+
{/if}
|
|
73
|
+
</Button>
|
|
74
|
+
|
|
75
|
+
<!-- Task Queue Drawer -->
|
|
76
|
+
{#if open}
|
|
77
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
78
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
79
|
+
<div class="fixed inset-0 z-[90] flex justify-end" onclick={() => open = false}>
|
|
80
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
81
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
82
|
+
<div
|
|
83
|
+
class="bg-card shadow-2xl w-full max-w-sm h-full flex flex-col border-l border-border animate-in slide-in-from-right duration-200"
|
|
84
|
+
onclick={(e) => e.stopPropagation()}
|
|
85
|
+
>
|
|
86
|
+
<!-- Header -->
|
|
87
|
+
<div class="p-4 border-b border-border flex items-center justify-between bg-muted/30">
|
|
88
|
+
<div class="flex items-center gap-2">
|
|
89
|
+
<ListTodo class="h-5 w-5 text-muted-foreground" />
|
|
90
|
+
<h2 class="text-sm font-semibold">Background Tasks</h2>
|
|
91
|
+
{#if runningCount > 0}
|
|
92
|
+
<Badge variant="secondary" class="text-xs">{runningCount} active</Badge>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={() => open = false}>
|
|
96
|
+
<X class="h-4 w-4" />
|
|
97
|
+
</Button>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Task List -->
|
|
101
|
+
<div class="flex-1 overflow-y-auto">
|
|
102
|
+
{#each tasks as task}
|
|
103
|
+
{@const Icon = statusIcon[task.status]}
|
|
104
|
+
<div class="p-4 border-b border-border hover:bg-muted/30 transition-colors">
|
|
105
|
+
<div class="flex items-start justify-between gap-2">
|
|
106
|
+
<div class="flex items-start gap-3 flex-1 min-w-0">
|
|
107
|
+
<div class="{statusColor[task.status]} mt-0.5 shrink-0">
|
|
108
|
+
<Icon class="h-4 w-4 {task.status === 'running' ? 'animate-spin' : ''}" />
|
|
109
|
+
</div>
|
|
110
|
+
<div class="flex-1 min-w-0">
|
|
111
|
+
<p class="text-sm font-medium truncate text-foreground">{task.title}</p>
|
|
112
|
+
{#if task.message}
|
|
113
|
+
<p class="text-xs text-muted-foreground mt-0.5">{task.message}</p>
|
|
114
|
+
{/if}
|
|
115
|
+
<p class="text-[10px] text-muted-foreground/60 mt-1 font-mono">{formatTime(task.createdAt)}</p>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<button
|
|
119
|
+
class="text-muted-foreground/40 hover:text-muted-foreground transition shrink-0"
|
|
120
|
+
onclick={() => dismissTask(task.id)}
|
|
121
|
+
>
|
|
122
|
+
<X class="h-3.5 w-3.5" />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<!-- Progress bar -->
|
|
127
|
+
{#if task.status === 'running' && task.progress != null}
|
|
128
|
+
<div class="mt-3 ml-7">
|
|
129
|
+
<div class="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
|
130
|
+
<div
|
|
131
|
+
class="h-full bg-primary rounded-full transition-all duration-500 ease-out"
|
|
132
|
+
style="width: {task.progress}%"
|
|
133
|
+
></div>
|
|
134
|
+
</div>
|
|
135
|
+
<p class="text-[10px] text-muted-foreground mt-1">{task.progress}%</p>
|
|
136
|
+
</div>
|
|
137
|
+
{/if}
|
|
138
|
+
|
|
139
|
+
<!-- Actions -->
|
|
140
|
+
{#if task.status === 'completed' && task.downloadUrl}
|
|
141
|
+
<div class="mt-2 ml-7">
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline" size="sm" class="h-7 text-xs"
|
|
144
|
+
onclick={() => onDownload?.(task.id, task.downloadUrl!)}
|
|
145
|
+
>
|
|
146
|
+
<Download class="h-3 w-3 mr-1" />
|
|
147
|
+
Download
|
|
148
|
+
</Button>
|
|
149
|
+
</div>
|
|
150
|
+
{:else if task.status === 'failed' && onRetry}
|
|
151
|
+
<div class="mt-2 ml-7">
|
|
152
|
+
<Button
|
|
153
|
+
variant="outline" size="sm" class="h-7 text-xs text-destructive"
|
|
154
|
+
onclick={() => onRetry?.(task.id)}
|
|
155
|
+
>
|
|
156
|
+
Retry
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
{/if}
|
|
160
|
+
</div>
|
|
161
|
+
{:else}
|
|
162
|
+
<div class="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
|
163
|
+
<ListTodo class="h-8 w-8 mb-2 opacity-30" />
|
|
164
|
+
<p class="text-sm">No background tasks</p>
|
|
165
|
+
</div>
|
|
166
|
+
{/each}
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
{/if}
|