diff --git a/backend/internal/handler/preferences.go b/backend/internal/handler/preferences.go new file mode 100644 index 0000000..8884dbb --- /dev/null +++ b/backend/internal/handler/preferences.go @@ -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}) +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index dcbe5a7..0785004 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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"` diff --git a/backend/router/router.go b/backend/router/router.go index fb6c518..004b9dc 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -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) diff --git a/frontend/src/hooks/useLogin.tsx b/frontend/src/hooks/useLogin.tsx index a0514d3..634829d 100644 --- a/frontend/src/hooks/useLogin.tsx +++ b/frontend/src/hooks/useLogin.tsx @@ -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 }; } diff --git a/frontend/src/pages/AppearanceSettings.tsx b/frontend/src/pages/AppearanceSettings.tsx index 7cc4004..86f5cac 100644 --- a/frontend/src/pages/AppearanceSettings.tsx +++ b/frontend/src/pages/AppearanceSettings.tsx @@ -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('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 ( +
+ +
+ ); + } return (
- {/* 主题模式 */} + {/* Theme Mode */}

主题模式

- {themeOptions.map(opt => ( + {([ + { id: 'dark' as const, icon: Moon, label: '深色模式' }, + { id: 'light' as const, icon: Sun, label: '浅色模式' }, + ]).map(opt => (
- {/* 登录页风格 */} + {/* Login Page Style */}

登录页风格

-

选择登录页面的视觉风格,退出登录后生效

+

选择登录页面的视觉风格,保存后退出登录即可看到效果

{LOGIN_STYLE_OPTIONS.map(opt => ( + {dirty && ( + 有未保存的更改 + )} +
); } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index ca6fa9f..437d353 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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('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 ; } diff --git a/frontend/src/pages/RolesPage.tsx b/frontend/src/pages/RolesPage.tsx index c9d058a..b1678eb 100644 --- a/frontend/src/pages/RolesPage.tsx +++ b/frontend/src/pages/RolesPage.tsx @@ -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 ( -
+
{/* Header */} -
-
-

角色权限

-

查看各角色的权限配置

-
+
+

角色权限

+

查看各角色的权限配置详情

- {/* Role Selector */} -
+ {/* Role Cards */} +
{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 ( ); })}
- {/* Permission Tree */} - - -
-
- - - {grantedPerms} / {totalPerms} 个权限已启用 - -
- {/* Search */} -
- - 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 && ( - - )} -
+ {/* Permission Detail Panel */} +
+ {/* Panel header */} +
+
+ + + {role.name} + + + {grantedPerms} / {totalPerms} 个权限已启用 ({pct}%) +
- - {/* Tree */} -
- {filteredTree.map(node => ( - - ))} - {filteredTree.length === 0 && ( -
未找到匹配的权限
+
+ + 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 && ( + )}
- - +
+ + {/* Category summary bar */} +
+ {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 ( +
+
{cat.label}
+
+
+
+
+ ); + })} +
+ + {/* Permission Tree */} +
+ {filteredTree.map(node => ( + + ))} + {filteredTree.length === 0 && ( +
未找到匹配的权限
+ )} +
+
); } -/* ───────── Permission Node (只读展示) ───────── */ +/* ───────── Permission Group (top-level) ───────── */ -function PermissionNode({ node, checkedKeys, depth }: { +function PermGroup({ node, checkedKeys, roleColor }: { node: typeof permissionTreeData[0]; checkedKeys: Set; - 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 ( -
-
+ {/* Group header */} + + ) : ( -
+
)} -
- {(isChecked || allChildrenChecked) && } - {someChildrenChecked && !allChildrenChecked && } + {/* Checkbox */} +
+ {(allChecked || isChecked) && } + {someChecked && !allChecked && !isChecked && }
- {node.label} -
+ {node.label} + {hasChildren && ( + + {checkedCount}/{node.children!.length} + + )} + + + {/* Children */} {hasChildren && expanded && ( -
+
{node.children!.map(child => ( - + ))}
)}
); } + +/* ───────── Permission Item (leaf) ───────── */ + +function PermItem({ node, checked }: { node: { id: string; label: string }; checked: boolean }) { + return ( +
+
+ {checked && } +
+ + {node.label} + +
+ ); +}