feat: 外观设置后端持久化 + 保存按钮 + 跨浏览器同步
后端: - User 模型新增 Preferences JSON 字段 (theme + login_style) - 新增 GET/PUT /api/v1/user/preferences (auth) 个人偏好读写 - 新增 GET /api/v1/public/login-style (public) 登录页风格公开端点 - SQLite users 表手动添加 preferences 列 前端: - AppearanceSettings 从后端加载偏好 + 保存按钮 + dirty 状态提示 - LoginPage 先从后端公开接口获取 login_style (跨浏览器一致) - useLogin 登录成功后从后端加载偏好并应用主题 - 偏好同时存 localStorage(即时) + 后端DB(跨设备)
This commit is contained in:
1 parent
8469df359f
commit
9055cc77ec
7 files changed
+369
-119
No files matched your search
@@ -1,38 +1,77 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
|
||||
import { Sun, Moon, Monitor, Check } from 'lucide-react';
|
||||
import { Sun, Moon, Check, Save, Loader2 } from 'lucide-react';
|
||||
import api from '@/api/request';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
export default function AppearanceSettings() {
|
||||
const { theme, setTheme, isDark } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [loginStyle, setLoginStyle] = useState<LoginStyle>('split');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
// Load preferences from backend on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
||||
if (stored) setLoginStyle(stored);
|
||||
api.get<{ theme?: string; login_style?: string }>('/user/preferences')
|
||||
.then(prefs => {
|
||||
if (prefs?.theme) setTheme(prefs.theme as 'dark' | 'light');
|
||||
if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle);
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to localStorage
|
||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
||||
if (stored) setLoginStyle(stored);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleThemeChange = (t: 'dark' | 'light') => {
|
||||
setTheme(t);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleLoginStyleChange = (id: LoginStyle) => {
|
||||
setLoginStyle(id);
|
||||
localStorage.setItem('login-style', id);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const themeOptions = [
|
||||
{ id: 'dark' as const, icon: Moon, label: '深色模式' },
|
||||
{ id: 'light' as const, icon: Sun, label: '浅色模式' },
|
||||
];
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put('/user/preferences', {
|
||||
theme,
|
||||
login_style: loginStyle,
|
||||
});
|
||||
localStorage.setItem('login-style', loginStyle);
|
||||
setDirty(false);
|
||||
toast('success', '外观设置已保存');
|
||||
} catch {
|
||||
toast('error', '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [theme, loginStyle]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 主题模式 */}
|
||||
{/* Theme Mode */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">主题模式</h3>
|
||||
<div className="flex gap-3">
|
||||
{themeOptions.map(opt => (
|
||||
{([
|
||||
{ id: 'dark' as const, icon: Moon, label: '深色模式' },
|
||||
{ id: 'light' as const, icon: Sun, label: '浅色模式' },
|
||||
]).map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => handleThemeChange(opt.id)}
|
||||
@@ -50,10 +89,10 @@ export default function AppearanceSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录页风格 */}
|
||||
{/* Login Page Style */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">登录页风格</h3>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择登录页面的视觉风格,退出登录后生效</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择登录页面的视觉风格,保存后退出登录即可看到效果</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{LOGIN_STYLE_OPTIONS.map(opt => (
|
||||
<button
|
||||
@@ -65,7 +104,7 @@ export default function AppearanceSettings() {
|
||||
: 'bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
{/* 预览缩略图 */}
|
||||
{/* Thumbnail preview */}
|
||||
<div className={`w-full aspect-[4/3] rounded-lg mb-3 overflow-hidden ${
|
||||
opt.id === 'split' ? 'flex' :
|
||||
opt.id === 'centered' ? 'flex items-center justify-center dg-gradient-br' :
|
||||
@@ -133,7 +172,7 @@ export default function AppearanceSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 选中标记 */}
|
||||
{/* Active check mark */}
|
||||
{loginStyle === opt.id && (
|
||||
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
@@ -146,6 +185,28 @@ export default function AppearanceSettings() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
dirty
|
||||
? 'dg-gradient-bg text-white hover:opacity-90'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> 保存中...</>
|
||||
) : (
|
||||
<><Save className="w-4 h-4" /> 保存设置</>
|
||||
)}
|
||||
</button>
|
||||
{dirty && (
|
||||
<span className="text-xs text-amber-400">有未保存的更改</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import SplitView from '@/components/login/SplitView';
|
||||
import CenteredCard from '@/components/login/CenteredCard';
|
||||
import SidePanel from '@/components/login/SidePanel';
|
||||
@@ -22,15 +23,32 @@ export const LOGIN_STYLE_OPTIONS: { id: LoginStyle; name: string; desc: string }
|
||||
{ id: 'minimal', name: '极简暗色', desc: '极致简约,纯暗色风格' },
|
||||
];
|
||||
|
||||
function getLoginStyle(): LoginStyle {
|
||||
if (typeof window === 'undefined') return 'split';
|
||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
||||
if (stored && stored in LOGIN_STYLES) return stored;
|
||||
return 'split';
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const style = getLoginStyle();
|
||||
const [style, setStyle] = useState<LoginStyle>('split');
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Try backend first (cross-browser), fallback to localStorage
|
||||
fetch('/api/v1/public/login-style')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const s = data?.login_style as LoginStyle;
|
||||
if (s && s in LOGIN_STYLES) {
|
||||
setStyle(s);
|
||||
} else {
|
||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
||||
if (stored && stored in LOGIN_STYLES) setStyle(stored);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const stored = localStorage.getItem('login-style') as LoginStyle | null;
|
||||
if (stored && stored in LOGIN_STYLES) setStyle(stored);
|
||||
})
|
||||
.finally(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
if (!ready) return null; // avoid flash
|
||||
|
||||
const Component = LOGIN_STYLES[style] || SplitView;
|
||||
return <Component />;
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Crown, Database, Code, Eye, ChevronRight, Check,
|
||||
Search, X, Users, Shield,
|
||||
Search, X, Shield, Users,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { permissionTreeData } from '@/lib/constants';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
@@ -14,43 +12,53 @@ interface RolePreset {
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
iconClass: string;
|
||||
color: string;
|
||||
bg: string;
|
||||
ring: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/* ───────── Role Definitions (固定预览) ───────── */
|
||||
/* ───────── Role Definitions ───────── */
|
||||
|
||||
const ROLES: RolePreset[] = [
|
||||
{
|
||||
key: 'admin',
|
||||
name: '超级管理员',
|
||||
description: '系统全部权限',
|
||||
description: '拥有系统全部权限,可管理所有功能模块和用户',
|
||||
icon: Crown,
|
||||
iconClass: 'text-yellow-400',
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-500/10',
|
||||
ring: 'ring-amber-500/20',
|
||||
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'roles', 'users', 'instance-groups', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'],
|
||||
},
|
||||
{
|
||||
key: 'dba',
|
||||
name: 'DBA',
|
||||
description: '数据库管理员:数据源管理、工单审批、查询权限',
|
||||
description: '数据库管理员,管理数据源、审批工单、查询数据',
|
||||
icon: Database,
|
||||
iconClass: 'text-blue-400',
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/10',
|
||||
ring: 'ring-blue-500/20',
|
||||
permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
||||
},
|
||||
{
|
||||
key: 'developer',
|
||||
name: '开发人员',
|
||||
description: '工单提交、查询执行、数据导出',
|
||||
description: '提交 SQL 工单、执行自助查询、导出结果',
|
||||
icon: Code,
|
||||
iconClass: 'text-green-400',
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-500/10',
|
||||
ring: 'ring-emerald-500/20',
|
||||
permissions: ['dashboard', 'tickets', 'ticket_create', 'queries', 'query_create', 'query_export'],
|
||||
},
|
||||
{
|
||||
key: 'auditor',
|
||||
name: '审计员',
|
||||
description: '查看查询日志、审计规则、审批流程',
|
||||
description: '查看查询日志、审计规则、审批流程记录',
|
||||
icon: Eye,
|
||||
iconClass: 'text-purple-400',
|
||||
color: 'text-violet-400',
|
||||
bg: 'bg-violet-500/10',
|
||||
ring: 'ring-violet-500/20',
|
||||
permissions: ['dashboard', 'tickets', 'queries', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'],
|
||||
},
|
||||
];
|
||||
@@ -97,131 +105,210 @@ export default function RolesPage() {
|
||||
|
||||
const totalPerms = ALL_PERM_IDS.length;
|
||||
const grantedPerms = role.permissions.filter(p => ALL_PERM_IDS.includes(p)).length;
|
||||
const pct = totalPerms > 0 ? Math.round((grantedPerms / totalPerms) * 100) : 0;
|
||||
|
||||
/* ──── Group permissions into categories for summary ──── */
|
||||
const categories = useMemo(() => {
|
||||
return permissionTreeData.map(group => {
|
||||
const all = group.children ? [group.id, ...group.children.map(c => c.id)] : [group.id];
|
||||
const granted = all.filter(id => checkedKeys.has(id)).length;
|
||||
return { label: group.label.split('(')[0], total: all.length, granted };
|
||||
});
|
||||
}, [checkedKeys]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">角色权限</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">查看各角色的权限配置</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">角色权限</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">查看各角色的权限配置详情</p>
|
||||
</div>
|
||||
|
||||
{/* Role Selector */}
|
||||
<div className="flex gap-3">
|
||||
{/* Role Cards */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{ROLES.map(r => {
|
||||
const Icon = r.icon;
|
||||
const isActive = selectedRole === r.key;
|
||||
const permCount = r.permissions.filter(p => ALL_PERM_IDS.includes(p)).length;
|
||||
return (
|
||||
<button
|
||||
key={r.key}
|
||||
onClick={() => setSelectedRole(r.key)}
|
||||
className={`flex items-center gap-2.5 px-4 py-3 rounded-lg transition-colors ${
|
||||
onClick={() => { setSelectedRole(r.key); setSearchQuery(''); }}
|
||||
className={`relative text-left p-4 rounded-xl transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]'
|
||||
: 'hover:bg-[var(--dg-bg-hover)]'
|
||||
? `bg-[var(--dg-bg-secondary)] ring-1 ${r.ring}`
|
||||
: 'bg-[var(--dg-bg-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${r.iconClass}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium">{r.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">{r.description}</div>
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<div className={`absolute top-0 left-0 right-0 h-0.5 rounded-t-xl dg-gradient-bg`} />
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg ${r.bg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className={`w-4.5 h-4.5 ${r.color}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{r.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5 line-clamp-1">{r.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permission count bar */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="flex-1 h-1 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
isActive ? 'dg-gradient-bg' : 'bg-[var(--dg-text-muted)]/30'
|
||||
}`}
|
||||
style={{ width: `${totalPerms > 0 ? (permCount / totalPerms) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] tabular-nums w-10 text-right">{permCount}/{totalPerms}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permission Tree */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{grantedPerms} / {totalPerms} 个权限已启用
|
||||
</span>
|
||||
</div>
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索权限..."
|
||||
className="w-full pl-9 pr-8 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Permission Detail Panel */}
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl p-5">
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm font-medium">
|
||||
{role.name}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
{grantedPerms} / {totalPerms} 个权限已启用 ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="space-y-0.5">
|
||||
{filteredTree.map(node => (
|
||||
<PermissionNode key={node.id} node={node} checkedKeys={checkedKeys} depth={0} />
|
||||
))}
|
||||
{filteredTree.length === 0 && (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)] text-sm">未找到匹配的权限</div>
|
||||
<div className="relative w-52">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索权限..."
|
||||
className="w-full pl-8 pr-7 py-1.5 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs placeholder:text-[var(--dg-text-muted)] focus:ring-1 focus:ring-blue-500/30 focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category summary bar */}
|
||||
<div className="flex gap-1.5 mb-5">
|
||||
{categories.map(cat => {
|
||||
const catPct = cat.total > 0 ? Math.round((cat.granted / cat.total) * 100) : 0;
|
||||
const isFull = catPct === 100;
|
||||
const isEmpty = catPct === 0;
|
||||
return (
|
||||
<div key={cat.label} className="flex-1 bg-[var(--dg-bg-primary)] rounded-lg p-2.5">
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mb-1 truncate">{cat.label}</div>
|
||||
<div className="h-1 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
isFull ? 'dg-gradient-bg' : isEmpty ? '' : 'bg-blue-400/50'
|
||||
}`}
|
||||
style={{ width: `${catPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Permission Tree */}
|
||||
<div className="space-y-px">
|
||||
{filteredTree.map(node => (
|
||||
<PermGroup key={node.id} node={node} checkedKeys={checkedKeys} roleColor={role.color} />
|
||||
))}
|
||||
{filteredTree.length === 0 && (
|
||||
<div className="text-center py-10 text-[var(--dg-text-muted)] text-sm">未找到匹配的权限</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Permission Node (只读展示) ───────── */
|
||||
/* ───────── Permission Group (top-level) ───────── */
|
||||
|
||||
function PermissionNode({ node, checkedKeys, depth }: {
|
||||
function PermGroup({ node, checkedKeys, roleColor }: {
|
||||
node: typeof permissionTreeData[0];
|
||||
checkedKeys: Set<string>;
|
||||
depth: number;
|
||||
roleColor: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isChecked = checkedKeys.has(node.id);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const allChildrenChecked = hasChildren && node.children!.every(c => checkedKeys.has(c.id));
|
||||
const someChildrenChecked = hasChildren && node.children!.some(c => checkedKeys.has(c.id));
|
||||
const isChecked = checkedKeys.has(node.id);
|
||||
const allChecked = hasChildren && node.children!.every(c => checkedKeys.has(c.id));
|
||||
const someChecked = hasChildren && node.children!.some(c => checkedKeys.has(c.id));
|
||||
const checkedCount = hasChildren ? node.children!.filter(c => checkedKeys.has(c.id)).length : 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-default"
|
||||
style={{ paddingLeft: `${depth * 20 + 12}px` }}
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2.5 hover:bg-[var(--dg-bg-hover)] transition-colors text-left"
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button onClick={() => setExpanded(!expanded)} className="p-0">
|
||||
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<ChevronRight className={`w-3.5 h-3.5 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
<div
|
||||
className={`w-4 h-4 rounded flex items-center justify-center ${
|
||||
isChecked || allChildrenChecked
|
||||
? 'bg-blue-500'
|
||||
: someChildrenChecked
|
||||
? 'bg-blue-500/40'
|
||||
: 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}
|
||||
>
|
||||
{(isChecked || allChildrenChecked) && <Check className="w-3 h-3 text-white" />}
|
||||
{someChildrenChecked && !allChildrenChecked && <Check className="w-3 h-3 text-white/60" />}
|
||||
{/* Checkbox */}
|
||||
<div className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
allChecked || isChecked ? `bg-blue-500` :
|
||||
someChecked ? 'bg-blue-500/40' :
|
||||
'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}>
|
||||
{(allChecked || isChecked) && <Check className="w-2.5 h-2.5 text-white" />}
|
||||
{someChecked && !allChecked && !isChecked && <Check className="w-2.5 h-2.5 text-white/60" />}
|
||||
</div>
|
||||
<span className="text-sm select-none">{node.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium flex-1 select-none">{node.label}</span>
|
||||
{hasChildren && (
|
||||
<span className={`text-[10px] tabular-nums px-1.5 py-0.5 rounded-full ${
|
||||
allChecked ? roleColor + ' bg-current/10' : 'text-[var(--dg-text-muted)]'
|
||||
}`}>
|
||||
{checkedCount}/{node.children!.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
<div className="pb-1">
|
||||
{node.children!.map(child => (
|
||||
<PermissionNode key={child.id} node={child} checkedKeys={checkedKeys} depth={depth + 1} />
|
||||
<PermItem key={child.id} node={child} checked={checkedKeys.has(child.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ───────── Permission Item (leaf) ───────── */
|
||||
|
||||
function PermItem({ node, checked }: { node: { id: string; label: string }; checked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 pl-12 pr-3 py-1.5 transition-colors"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
checked ? 'bg-blue-500' : 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}>
|
||||
{checked && <Check className="w-2 h-2 text-white" />}
|
||||
</div>
|
||||
<span className={`text-xs select-none ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]'}`}>
|
||||
{node.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user