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:
logan committed 2026-05-26 22:09:47 +08:00
1 parent 8469df359f
commit 9055cc77ec
7 files changed
+369 -119

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
package handler
import (
"encoding/json"
"net/http"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
)
// GET /api/v1/user/preferences
func GetUserPreferences(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
return
}
prefs := map[string]string{}
if user.Preferences != nil && *user.Preferences != "" {
json.Unmarshal([]byte(*user.Preferences), &prefs)
}
c.JSON(http.StatusOK, prefs)
}
// PUT /api/v1/user/preferences
func UpdateUserPreferences(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var prefs map[string]string
if err := c.ShouldBindJSON(&prefs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
return
}
data, _ := json.Marshal(prefs)
str := string(data)
result := model.DB.Model(&model.User{}).Where("id = ?", userID).Update("preferences", str)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": result.Error.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences updated", "preferences": prefs})
}
// GET /api/v1/public/login-style (no auth required)
func GetPublicLoginStyle(c *gin.Context) {
// Check system settings first
var setting model.SystemSetting
style := "split" // default
if model.DB.Where("`key` = ?", "login_style").First(&setting).Error == nil {
if setting.Value != "" {
style = setting.Value
}
}
// Also return theme default
theme := "dark"
if model.DB.Where("`key` = ?", "default_theme").First(&setting).Error == nil {
if setting.Value != "" {
theme = setting.Value
}
}
c.JSON(http.StatusOK, gin.H{"login_style": style, "theme": theme})
}
+1
View File
@@ -18,6 +18,7 @@ type User struct {
HashedPassword string `gorm:"size:200;not null" json:"-"`
Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor
IsActive *bool `gorm:"default:true" json:"is_active"`
Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"dark","login_style":"split"}
LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"`
+5
View File
@@ -12,6 +12,7 @@ func Setup(r *gin.Engine) {
// Public routes
api.POST("/auth/login", handler.Login)
api.GET("/public/login-style", handler.GetPublicLoginStyle)
// Authenticated routes
auth := api.Group("")
@@ -126,6 +127,10 @@ func Setup(r *gin.Engine) {
ldapAdmin.POST("/ldap-config/delete-user", handler.DeleteLDAPUser)
}
// User Preferences (per-user, auth required)
auth.GET("/user/preferences", handler.GetUserPreferences)
auth.PUT("/user/preferences", handler.UpdateUserPreferences)
// Settings
auth.GET("/settings", handler.ListSettings)
auth.PUT("/settings", handler.UpdateSettings)
+11 -2
View File
@@ -1,6 +1,8 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import api from '@/api/request';
export interface LoginState {
username: string;
@@ -15,6 +17,7 @@ export interface LoginState {
export function useLogin(): LoginState {
const navigate = useNavigate();
const { login } = useAuth();
const { setTheme } = useTheme();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
@@ -26,13 +29,19 @@ export function useLogin(): LoginState {
setLoading(true);
setError('');
const ok = await login(username, password);
setLoading(false);
if (ok) {
// Apply user preferences from backend (cross-browser sync)
try {
const prefs = await api.get<{ theme?: string; login_style?: string }>('/user/preferences');
if (prefs?.theme) setTheme(prefs.theme as 'dark' | 'light');
if (prefs?.login_style) localStorage.setItem('login-style', prefs.login_style);
} catch { /* ignore */ }
navigate('/dashboard');
} else {
setError('用户名或密码错误');
}
}, [username, password, login, navigate]);
setLoading(false);
}, [username, password, login, navigate, setTheme]);
return { username, password, loading, error, setUsername, setPassword, handleLogin };
}
+77 -16
View File
@@ -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>
);
}
+26 -8
View File
@@ -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 />;
}
+180 -93
View File
@@ -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>
);
}