feat(rbac): F3 前端 RBAC 按钮级控制
后端: - 新增 GET /user/permissions 端点返回当前用户权限码 - Login 响应附带 permissions 数组 - collectUserPermissionCodes 用 raw SQL JOIN 避免 GORM many2many 表名问题 - admin 用户返回 ['*'] 通配符 前端: - usePermission hook (hasPermission/hasAnyPermission/hasAllPermissions) - <HasPermission> 组件 (permission/anyOf/allOf + fallback) - zustand store 新增 permissions 字段 - Sidebar 菜单递归过滤 (42 菜单项全部标注 permission) - ServerManager: 添加/导入/批量删除按钮按权限显隐 - ServerTableRow: 终端/编辑/文件/删除按权限显隐 - SystemUserTab: 新建/导入/编辑/删除按钮按权限显隐 - MainLayout: 页面刷新自动拉取权限 - Login: 登录时存储 permissions 验证: - admin: permissions=['*'] 通配符 - viewer: 7 个只读权限 (dashboard:view/server:view/...) - viewer 无 server:create/delete - 10/10 回归测试通过 - npm run build 零错误
This commit is contained in:
1 parent
77c234b060
commit
be2a377f01
30 files changed
+2328
-462
No files matched your search
@@ -35,6 +35,7 @@ const ApprovalFlowManager = lazy(() => import('@/components/approval/ApprovalFlo
|
||||
const ApprovalResourcePage = lazy(() => import('@/pages/ApprovalResourcePage').then(m => ({ default: m.ApprovalResourcePage })));
|
||||
const ApprovalAppPage = lazy(() => import('@/pages/ApprovalAppPage').then(m => ({ default: m.ApprovalAppPage })));
|
||||
const ApprovalPermissionPage = lazy(() => import('@/pages/ApprovalPermissionPage').then(m => ({ default: m.ApprovalPermissionPage })));
|
||||
const ApprovalWorkflowPage = lazy(() => import('@/pages/ApprovalWorkflowPage').then(m => ({ default: m.default })));
|
||||
const ScriptManager = lazy(() => import('@/components/automation/ScriptManager').then(m => ({ default: m.ScriptManager })));
|
||||
const SystemManager = lazy(() => import('@/components/system/SystemManager').then(m => ({ default: m.SystemManager })));
|
||||
const AuditPage = lazy(() => import('@/pages/AuditPage').then(m => ({ default: m.AuditPage })));
|
||||
@@ -199,6 +200,7 @@ function App() {
|
||||
<Route path="roles" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="credentials" element={<LazyPage><CredentialManager /></LazyPage>} />
|
||||
<Route path="settings" element={<LazyPage><SystemManager /></LazyPage>} />
|
||||
<Route path="approval-workflow" element={<LazyPage><ApprovalWorkflowPage /></LazyPage>} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -85,8 +85,8 @@ export function Login() {
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await userApi.login(username, password) as { user: any; token: string };
|
||||
useUserStore.getState().login(result.user, result.token);
|
||||
const result = await userApi.login(username, password) as { user: any; token: string; permissions?: string[] };
|
||||
useUserStore.getState().login(result.user, result.token, result.permissions ?? null);
|
||||
localStorage.setItem('token', result.token);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -51,6 +51,7 @@ const ROUTE_NAME_MAP: Record<string, string> = {
|
||||
'approval-resource': '资源申请',
|
||||
'approval-app': '应用申请',
|
||||
'approval-permission': '权限审批',
|
||||
'approval-workflow': '审批流程',
|
||||
'scripts': '脚本库',
|
||||
'ansible': 'Ansible',
|
||||
'ai-assistant': '智能助手',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore } from '@/store';
|
||||
import { useThemeStore, useUserStore } from '@/store';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { Breadcrumb } from './Breadcrumb';
|
||||
import { userApi } from '@/services/api';
|
||||
|
||||
/**
|
||||
* MainLayout - 优化后的主布局组件
|
||||
@@ -14,9 +16,32 @@ import { Breadcrumb } from './Breadcrumb';
|
||||
* 3. 添加 Breadcrumb 面包屑导航
|
||||
* 4. 布局过渡动画平滑
|
||||
* 5. 支持响应式布局
|
||||
* 6. F3: 页面刷新后自动拉取当前用户权限(RBAC 按钮级控制)
|
||||
*/
|
||||
export function MainLayout() {
|
||||
const { sidebarCollapsed } = useThemeStore();
|
||||
const token = useUserStore((s) => s.token);
|
||||
const permissions = useUserStore((s) => s.permissions);
|
||||
const setPermissions = useUserStore((s) => s.setPermissions);
|
||||
|
||||
// 页面刷新后 zustand persist 会恢复 token/user,但 permissions 可能丢失或过期
|
||||
// 在 MainLayout 挂载时如果 permissions 为 null 则拉取一次
|
||||
useEffect(() => {
|
||||
if (!token || permissions !== null) return;
|
||||
let cancelled = false;
|
||||
userApi.getMyPermissions()
|
||||
.then((data: any) => {
|
||||
if (cancelled) return;
|
||||
const perms: string[] = data?.permissions ?? [];
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
})
|
||||
.catch(() => {
|
||||
// 拉取失败不阻塞页面,权限保持 null(usePermission 默认放行)
|
||||
if (!cancelled) setPermissions([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect} from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useMenuStore } from '@/store';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import {
|
||||
|
||||
LayoutDashboard,
|
||||
@@ -50,113 +51,116 @@ interface MenuItem {
|
||||
icon: React.ElementType;
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
/** RBAC 权限码 — 用户需拥有此权限才显示该菜单项;不填=所有人可见 */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' },
|
||||
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/', permission: 'dashboard:view' },
|
||||
{
|
||||
id: 'assets', name: '资产管理', icon: Server,
|
||||
id: 'assets', name: '资产管理', icon: Server, permission: 'server:view',
|
||||
children: [
|
||||
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' },
|
||||
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' },
|
||||
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' },
|
||||
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
|
||||
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
|
||||
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing' },
|
||||
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers', permission: 'server:view' },
|
||||
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts', permission: 'cloud:account' },
|
||||
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains', permission: 'cloud:domain' },
|
||||
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions', permission: 'cloud:account' },
|
||||
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms', permission: 'cloud:account' },
|
||||
{ id: 'cloud-billing', name: '云账单', icon: BarChart3, path: '/cloud-billing', permission: 'cloud:billing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kubernetes', name: 'Kubernetes', icon: Cloud,
|
||||
id: 'kubernetes', name: 'Kubernetes', icon: Cloud, permission: 'k8s:cluster',
|
||||
children: [
|
||||
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes' },
|
||||
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
|
||||
{ id: 'k8s-clusters', name: '集群列表', icon: Cloud, path: '/kubernetes', permission: 'k8s:cluster' },
|
||||
{ id: 'k8s-nodes', name: '节点管理', icon: Cpu, path: '/nodes', permission: 'k8s:nodes' },
|
||||
{
|
||||
id: 'k8s-workload', name: '工作负载', icon: Boxes,
|
||||
id: 'k8s-workload', name: '工作负载', icon: Boxes, permission: 'k8s:workloads',
|
||||
children: [
|
||||
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments' },
|
||||
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets' },
|
||||
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets' },
|
||||
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs' },
|
||||
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods' },
|
||||
{ id: 'k8s-deployments', name: 'Deployments', icon: Boxes, path: '/workloads/deployments', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-statefulsets', name: 'StatefulSets', icon: Boxes, path: '/workloads/statefulsets', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-daemonsets', name: 'DaemonSets', icon: Boxes, path: '/workloads/daemonsets', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-cronjobs', name: 'CronJobs', icon: Clock, path: '/workloads/cronjobs', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-pods', name: 'Pods', icon: Container, path: '/workloads/pods', permission: 'k8s:workloads' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'k8s-network', name: '网络', icon: Network,
|
||||
id: 'k8s-network', name: '网络', icon: Network, permission: 'k8s:workloads',
|
||||
children: [
|
||||
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services' },
|
||||
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses' },
|
||||
{ id: 'k8s-services', name: 'Services', icon: Network, path: '/workloads/services', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-ingresses', name: 'Ingresses', icon: Globe, path: '/workloads/ingresses', permission: 'k8s:workloads' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'k8s-config', name: '配置中心', icon: Settings,
|
||||
id: 'k8s-config', name: '配置中心', icon: Settings, permission: 'k8s:workloads',
|
||||
children: [
|
||||
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps' },
|
||||
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets' },
|
||||
{ id: 'k8s-configmaps', name: 'ConfigMaps', icon: FileCode, path: '/workloads/configmaps', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-secrets', name: 'Secrets', icon: Shield, path: '/workloads/secrets', permission: 'k8s:workloads' },
|
||||
],
|
||||
},
|
||||
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs' },
|
||||
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics' },
|
||||
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events' },
|
||||
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity' },
|
||||
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology' },
|
||||
{ id: 'k8s-storage', name: '存储', icon: Database, path: '/workloads/pvcs', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-metrics', name: '资源用量', icon: Activity, path: '/workloads/metrics', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-events', name: '事件', icon: Bell, path: '/workloads/events', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-capacity', name: '容量分析', icon: BarChart3, path: '/workloads/capacity', permission: 'k8s:workloads' },
|
||||
{ id: 'k8s-topology', name: '架构拓扑', icon: Network, path: '/topology', permission: 'k8s:topology' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deploy', name: '应用发布', icon: Rocket,
|
||||
id: 'deploy', name: '应用发布', icon: Rocket, permission: 'deploy:pipeline',
|
||||
children: [
|
||||
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines' },
|
||||
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools' },
|
||||
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac' },
|
||||
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines', permission: 'deploy:pipeline' },
|
||||
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/configureTools', permission: 'deploy:release' },
|
||||
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac', permission: 'deploy:release' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'monitor', name: '监控中心', icon: Bell,
|
||||
id: 'monitor', name: '监控中心', icon: Bell, permission: 'monitor:alert',
|
||||
children: [
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards' },
|
||||
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center' },
|
||||
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
|
||||
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
|
||||
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources' },
|
||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/dashboards', permission: 'monitor:alert' },
|
||||
{ id: 'monitor-center', name: '资源监控', icon: Activity, path: '/monitor-center', permission: 'agent:monitor' },
|
||||
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace', permission: 'monitor:alert' },
|
||||
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center', permission: 'monitor:alert' },
|
||||
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources', permission: 'monitor:alert' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation', name: '自动化运维', icon: PlayCircle,
|
||||
id: 'automation', name: '自动化运维', icon: PlayCircle, permission: 'auto:cron',
|
||||
children: [
|
||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
||||
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible' },
|
||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs', permission: 'auto:cron' },
|
||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts', permission: 'auto:script' },
|
||||
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/ansible', permission: 'auto:ansible' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'aiops', name: 'AIOps智能', icon: Brain,
|
||||
id: 'aiops', name: 'AIOps智能', icon: Brain, permission: 'aiops:assistant',
|
||||
children: [
|
||||
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant' },
|
||||
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer' },
|
||||
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive' },
|
||||
{ id: 'ai-assistant', name: '智能助手', icon: Bot, path: '/ai-assistant', permission: 'aiops:assistant' },
|
||||
{ id: 'ai-alert-analyzer', name: '告警分析', icon: AlertTriangle, path: '/ai-alert-analyzer', permission: 'aiops:analysis' },
|
||||
{ id: 'ai-predictive', name: '容量预测', icon: BarChart3, path: '/ai-predictive', permission: 'aiops:forecast' },
|
||||
],
|
||||
},
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare,
|
||||
{ id: 'approval', name: '资源申请', icon: CheckSquare, permission: 'approval:apply',
|
||||
children: [
|
||||
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource' },
|
||||
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app' },
|
||||
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission' },
|
||||
{ id: 'approval-resource', name: '资源申请', icon: Server, path: '/approval/resource', permission: 'approval:apply' },
|
||||
{ id: 'approval-app', name: '应用申请', icon: Rocket, path: '/approval/app', permission: 'approval:apply' },
|
||||
{ id: 'approval-permission', name: '权限审批', icon: ShieldCheck, path: '/approval/permission', permission: 'approval:apply' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'audit', name: '安全审计', icon: FileSearch,
|
||||
id: 'audit', name: '安全审计', icon: FileSearch, permission: 'audit:terminal',
|
||||
children: [
|
||||
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal' },
|
||||
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations' },
|
||||
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs' },
|
||||
{ id: 'audit-terminal', name: '终端审计', icon: Terminal, path: '/audit/terminal', permission: 'audit:terminal' },
|
||||
{ id: 'audit-operations', name: '操作审计', icon: FileCode, path: '/audit/operations', permission: 'audit:operations' },
|
||||
{ id: 'audit-login', name: '登录日志', icon: LogIn, path: '/audit/login-logs', permission: 'audit:login-logs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system', name: '系统管理', icon: Settings,
|
||||
id: 'system', name: '系统管理', icon: Settings, permission: 'system:user',
|
||||
children: [
|
||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
|
||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||
{ id: 'users', name: '用户管理', icon: Users, path: '/users', permission: 'system:user' },
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles', permission: 'system:role' },
|
||||
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials', permission: 'system:user' },
|
||||
{ id: 'approval-workflow', name: '审批流程', icon: GitBranch, path: '/approval-workflow', permission: 'system:role' },
|
||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings', permission: 'system:user' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -321,6 +325,7 @@ function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number
|
||||
*/
|
||||
export function Sidebar() {
|
||||
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
// Listen for keyboard shortcut
|
||||
useEffect(() => {
|
||||
@@ -329,6 +334,20 @@ export function Sidebar() {
|
||||
return () => window.removeEventListener('toggle-sidebar' as any, handler);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// F3: 按权限过滤菜单(递归)
|
||||
// 父级菜单:自身权限通过 且 至少有一个子菜单可见
|
||||
const filterMenu = (items: MenuItem[]): MenuItem[] => {
|
||||
return items
|
||||
.filter((item) => !item.permission || hasPermission(item.permission))
|
||||
.map((item) => {
|
||||
if (!item.children) return item;
|
||||
const visibleChildren = filterMenu(item.children);
|
||||
return { ...item, children: visibleChildren };
|
||||
})
|
||||
.filter((item) => !item.children || item.children.length > 0);
|
||||
};
|
||||
const visibleMenuItems = filterMenu(menuItems);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -365,7 +384,7 @@ export function Sidebar() {
|
||||
{/* Menu Items */}
|
||||
<ScrollArea className="flex-1 py-2 px-1.5">
|
||||
<nav className="space-y-0.5">
|
||||
{menuItems.map((item) => (
|
||||
{visibleMenuItems.map((item) => (
|
||||
<MenuItemComponent key={item.id} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* HasPermission — 条件渲染子组件(基于 RBAC 权限码)
|
||||
*
|
||||
* 用法:
|
||||
* <HasPermission permission="server:create">
|
||||
* <Button>新增服务器</Button>
|
||||
* </HasPermission>
|
||||
*
|
||||
* <HasPermission anyOf={['server:update', 'server:delete']}>
|
||||
* <Button>操作</Button>
|
||||
* </HasPermission>
|
||||
*
|
||||
* <HasPermission allOf={['server:update', 'server:webshell']}>
|
||||
* <Button>运维操作</Button>
|
||||
* </HasPermission>
|
||||
*
|
||||
* <HasPermission permission="server:delete" fallback={<span className="text-xs text-muted">无权限</span>}>
|
||||
* <Button>删除</Button>
|
||||
* </HasPermission>
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface HasPermissionProps {
|
||||
/** 单个权限码 */
|
||||
permission?: string;
|
||||
/** 任意一个即可(OR) */
|
||||
anyOf?: string[];
|
||||
/** 全部都要(AND) */
|
||||
allOf?: string[];
|
||||
/** 无权限时渲染的兜底内容 */
|
||||
fallback?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function HasPermission({
|
||||
permission,
|
||||
anyOf,
|
||||
allOf,
|
||||
fallback = null,
|
||||
children,
|
||||
}: HasPermissionProps) {
|
||||
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
|
||||
|
||||
let ok = true;
|
||||
if (permission) ok = ok && hasPermission(permission);
|
||||
if (anyOf && anyOf.length > 0) ok = ok && hasAnyPermission(anyOf);
|
||||
if (allOf && allOf.length > 0) ok = ok && hasAllPermissions(allOf);
|
||||
|
||||
return <>{ok ? children : fallback}</>;
|
||||
}
|
||||
@@ -51,9 +51,11 @@ import { ExcelServerImport } from './ExcelServerImport';
|
||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||
import { getTags } from './serverHelpers';
|
||||
import { ServerTableRow } from './ServerTableRow';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
export function ServerManager() {
|
||||
const confirm = useConfirm();
|
||||
const { hasPermission } = usePermission();
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedServers, setSelectedServers] = useState<string[]>([]);
|
||||
@@ -392,6 +394,7 @@ export function ServerManager() {
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
{hasPermission('server:create') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]">
|
||||
@@ -420,13 +423,16 @@ export function ServerManager() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
onClick={openCreateForm}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('server:create') && (
|
||||
<Button
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
onClick={openCreateForm}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加服务器
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -464,6 +470,7 @@ export function ServerManager() {
|
||||
{selectedServers.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
|
||||
{hasPermission('server:delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -473,6 +480,7 @@ export function ServerManager() {
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
批量删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface ServerTableRowProps {
|
||||
server: any;
|
||||
@@ -34,6 +35,7 @@ export function ServerTableRow({
|
||||
onOpenFiles,
|
||||
onDelete,
|
||||
}: ServerTableRowProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
const cpuUsage = Number(server.cpu_usage || 0);
|
||||
const memoryUsage = Number(server.memory_usage || 0);
|
||||
const tags = getTags(server);
|
||||
@@ -147,9 +149,12 @@ export function ServerTableRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasPermission('server:webshell') && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title="终端登录">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||||
@@ -159,6 +164,7 @@ export function ServerTableRow({
|
||||
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
{hasPermission('server:update') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenEdit(server)}
|
||||
@@ -166,6 +172,8 @@ export function ServerTableRow({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:webshell') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenTerminal(server)}
|
||||
@@ -173,6 +181,8 @@ export function ServerTableRow({
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
终端
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:update') && (
|
||||
<DropdownMenuItem
|
||||
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
|
||||
onClick={() => onOpenFiles?.(server)}
|
||||
@@ -180,6 +190,9 @@ export function ServerTableRow({
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
文件
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasPermission('server:delete') && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:bg-[var(--dg-bg-hover)] focus:text-red-400 cursor-pointer"
|
||||
@@ -188,8 +201,11 @@ export function ServerTableRow({
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -4,12 +4,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Shield } from 'lucide-react';
|
||||
|
||||
@@ -38,37 +33,69 @@ interface RoleFormDialogProps {
|
||||
permissions: PermissionOption[];
|
||||
}
|
||||
|
||||
// 权限按模块分组
|
||||
const MODULE_GROUPS: { key: string; label: string; prefixes: string[] }[] = [
|
||||
{ key: 'dashboard', label: '📊 仪表盘', prefixes: ['dashboard:'] },
|
||||
{ key: 'server', label: '🖥️ 资产管理', prefixes: ['server:'] },
|
||||
{ key: 'cloud', label: '☁️ 云资源', prefixes: ['cloud:'] },
|
||||
{ key: 'k8s', label: '☸️ Kubernetes', prefixes: ['k8s:'] },
|
||||
{ key: 'deploy', label: '🚀 应用发布', prefixes: ['deploy:'] },
|
||||
{ key: 'monitor', label: '📡 监控告警', prefixes: ['monitor:', 'agent:'] },
|
||||
{ key: 'auto', label: '⚙️ 自动化运维', prefixes: ['auto:'] },
|
||||
{ key: 'aiops', label: '🤖 AIOps智能', prefixes: ['aiops:'] },
|
||||
{ key: 'approval', label: '✅ 资源申请', prefixes: ['approval:'] },
|
||||
{ key: 'audit', label: '🔍 安全审计', prefixes: ['audit:'] },
|
||||
{ key: 'system', label: '🔧 系统管理', prefixes: ['system:'] },
|
||||
];
|
||||
|
||||
function groupPermissions(perms: PermissionOption[]): { key: string; label: string; items: PermissionOption[] }[] {
|
||||
const grouped = MODULE_GROUPS.map(g => ({
|
||||
key: g.key,
|
||||
label: g.label,
|
||||
items: perms.filter(p => g.prefixes.some(pre => p.code.startsWith(pre))),
|
||||
})).filter(g => g.items.length > 0);
|
||||
|
||||
// 未分类的权限
|
||||
const classified = new Set(grouped.flatMap(g => g.items.map(p => p.id)));
|
||||
const uncategorized = perms.filter(p => !classified.has(p.id));
|
||||
if (uncategorized.length > 0) {
|
||||
grouped.push({ key: 'other', label: '📌 其他', items: uncategorized });
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function RoleFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingRole,
|
||||
form,
|
||||
setForm,
|
||||
saving,
|
||||
onSubmit,
|
||||
permissions,
|
||||
open, onOpenChange, editingRole, form, setForm, saving, onSubmit, permissions,
|
||||
}: RoleFormDialogProps) {
|
||||
const togglePermission = (id: number, checked: boolean) => {
|
||||
setForm((prev) => {
|
||||
const set = new Set(prev.permission_ids);
|
||||
if (checked) {
|
||||
set.add(id);
|
||||
} else {
|
||||
set.delete(id);
|
||||
}
|
||||
checked ? set.add(id) : set.delete(id);
|
||||
return { ...prev, permission_ids: Array.from(set) };
|
||||
});
|
||||
};
|
||||
|
||||
// 全选/取消某个分组
|
||||
const toggleGroup = (items: PermissionOption[], checked: boolean) => {
|
||||
setForm((prev) => {
|
||||
const set = new Set(prev.permission_ids);
|
||||
items.forEach(p => checked ? set.add(p.id) : set.delete(p.id));
|
||||
return { ...prev, permission_ids: Array.from(set) };
|
||||
});
|
||||
};
|
||||
|
||||
const grouped = groupPermissions(permissions);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-purple-400" />
|
||||
{editingRole ? '编辑角色' : '新建角色'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -76,68 +103,95 @@ export function RoleFormDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-500">角色名称</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">角色名称</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder="如:运维工程师"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-500">角色编码</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">角色编码</Label>
|
||||
<Input
|
||||
value={form.code}
|
||||
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||
disabled={!!editingRole}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 disabled:opacity-60"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] disabled:opacity-60"
|
||||
placeholder="如:ops_engineer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-500">描述</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 resize-none"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] resize-none"
|
||||
placeholder="角色职责描述(可选)"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 权限分配 — 按模块分组 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-slate-500">
|
||||
<Label className="text-xs text-[var(--dg-text-secondary)]">
|
||||
权限分配
|
||||
{form.permission_ids.length > 0 && (
|
||||
<span className="ml-2 text-slate-400">已选 {form.permission_ids.length} 项</span>
|
||||
<span className="ml-2 text-purple-400">已选 {form.permission_ids.length} 项</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-slate-800 bg-slate-800/30 p-3">
|
||||
{permissions.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 text-center py-4">暂无可分配权限</p>
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] p-3 space-y-3">
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center py-4">暂无可分配权限</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{permissions.map((perm) => (
|
||||
<Label
|
||||
key={perm.id}
|
||||
className="flex items-center gap-2 text-xs text-slate-300 font-normal cursor-pointer hover:text-slate-100"
|
||||
>
|
||||
<Checkbox
|
||||
checked={form.permission_ids.includes(perm.id)}
|
||||
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
|
||||
className="border-slate-600 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
|
||||
/>
|
||||
<span className="truncate">{perm.name}</span>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
grouped.map(group => {
|
||||
const selectedInGroup = group.items.filter(p => form.permission_ids.includes(p.id)).length;
|
||||
const allSelected = selectedInGroup === group.items.length && group.items.length > 0;
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.items, !allSelected)}
|
||||
className="text-xs font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors flex items-center gap-1"
|
||||
>
|
||||
{group.label}
|
||||
<span className="text-[var(--dg-text-muted)] text-[10px]">
|
||||
({selectedInGroup}/{group.items.length})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleGroup(group.items, !allSelected)}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 pl-1">
|
||||
{group.items.map((perm) => (
|
||||
<Label
|
||||
key={perm.id}
|
||||
className="flex items-center gap-2 text-xs text-[var(--dg-text-secondary)] font-normal cursor-pointer hover:text-[var(--dg-text-primary)] py-0.5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={form.permission_ids.includes(perm.id)}
|
||||
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
|
||||
className="border-[var(--dg-border)] data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600 h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate">{perm.name}</span>
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}>
|
||||
|
||||
@@ -4,61 +4,81 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Shield, Edit, Eye, Trash2 } from 'lucide-react';
|
||||
import { Plus, Shield, ShieldCheck, Edit, Eye, Trash2, Lock, Star } from 'lucide-react';
|
||||
import { userApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import type { Role } from '@/types';
|
||||
import {
|
||||
RoleFormDialog,
|
||||
type RoleFormData,
|
||||
type PermissionOption,
|
||||
} from './RoleFormDialog';
|
||||
import { RoleFormDialog, type RoleFormData, type PermissionOption } from './RoleFormDialog';
|
||||
|
||||
interface SystemRolesTabProps {
|
||||
selectedRole: Role | null;
|
||||
setSelectedRole: (role: Role | null) => void;
|
||||
}
|
||||
|
||||
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
|
||||
function normalizeRole( raw: any): Role {
|
||||
// 角色图标映射
|
||||
const ROLE_ICONS: Record<string, string> = {
|
||||
super_admin: '👑',
|
||||
k8s_admin: '☸️',
|
||||
resource_admin: '🖥️',
|
||||
monitor_admin: '📡',
|
||||
security_auditor: '🔍',
|
||||
release_admin: '🚀',
|
||||
viewer: '👁️',
|
||||
};
|
||||
|
||||
// 权限按模块分组
|
||||
const PERM_MODULE_GROUPS: { label: string; prefixes: string[] }[] = [
|
||||
{ label: '仪表盘', prefixes: ['dashboard:'] },
|
||||
{ label: '资产管理', prefixes: ['server:'] },
|
||||
{ label: '云资源', prefixes: ['cloud:'] },
|
||||
{ label: 'K8s', prefixes: ['k8s:'] },
|
||||
{ label: '发布', prefixes: ['deploy:'] },
|
||||
{ label: '监控', prefixes: ['monitor:', 'agent:'] },
|
||||
{ label: '自动化', prefixes: ['auto:'] },
|
||||
{ label: 'AI', prefixes: ['aiops:'] },
|
||||
{ label: '审批', prefixes: ['approval:'] },
|
||||
{ label: '审计', prefixes: ['audit:'] },
|
||||
{ label: '系统', prefixes: ['system:'] },
|
||||
];
|
||||
|
||||
function groupPermNames(perms: string[], codes: string[]): { label: string; count: number }[] {
|
||||
const grouped = PERM_MODULE_GROUPS.map(g => {
|
||||
const matched = perms.filter((_, i) => g.prefixes.some(pre => (codes[i] || '').startsWith(pre)));
|
||||
return { label: g.label, count: matched.length };
|
||||
}).filter(g => g.count > 0);
|
||||
return grouped;
|
||||
}
|
||||
|
||||
// 将后端角色数据规范化为前端 Role 类型
|
||||
function normalizeRole(raw: any): Role {
|
||||
const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : [];
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
name: raw.name ?? '',
|
||||
code: raw.code ?? '',
|
||||
description: raw.description ?? '',
|
||||
// 后端返回权限对象数组(含 name/code/type),映射为显示名数组
|
||||
permissions: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
|
||||
permissionCodes: rawPerms.map(( p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.name ?? p?.code ?? '')).filter(Boolean),
|
||||
permissionCodes: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
||||
// 保留原始权限 ID 供编辑使用
|
||||
isSystem: raw.is_system ?? raw.isSystem ?? false,
|
||||
_permissionIds: rawPerms
|
||||
.map(( p: any) => (typeof p === 'object' ? Number(p.id) : 0))
|
||||
.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0))
|
||||
.filter(Boolean),
|
||||
} as Role & { _permissionIds: number[]; permissionCodes: string[] };
|
||||
_raw: raw,
|
||||
} as Role & { _permissionIds: number[]; permissionCodes: string[]; isSystem: boolean; _raw: any };
|
||||
}
|
||||
|
||||
const emptyForm: RoleFormData = {
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
permission_ids: [],
|
||||
name: '', code: '', description: '', permission_ids: [],
|
||||
};
|
||||
|
||||
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
|
||||
const confirm = useConfirm();
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [roles, setRoles] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [permissions, setPermissions] = useState<PermissionOption[]>([]);
|
||||
|
||||
// 新建 / 编辑弹窗
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<any | null>(null);
|
||||
const [form, setForm] = useState<RoleFormData>({ ...emptyForm });
|
||||
@@ -82,11 +102,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
const resp = await userApi.getPermissions();
|
||||
const list = resp?.list ?? [];
|
||||
setPermissions(
|
||||
list.map(( p: any) => ({
|
||||
id: Number(p.id),
|
||||
name: p.name ?? p.code ?? '',
|
||||
code: p.code ?? '',
|
||||
type: p.type,
|
||||
list.map((p: any) => ({
|
||||
id: Number(p.id), name: p.name ?? p.code ?? '', code: p.code ?? '', type: p.type,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
@@ -105,8 +122,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = ( role: any) => {
|
||||
setEditingRole(role);
|
||||
const openEdit = (role: any) => {
|
||||
setEditingRole(role._raw || role);
|
||||
setForm({
|
||||
name: role.name ?? '',
|
||||
code: role.code ?? '',
|
||||
@@ -117,14 +134,8 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('请输入角色名称');
|
||||
return;
|
||||
}
|
||||
if (!form.code.trim()) {
|
||||
toast.error('请输入角色编码');
|
||||
return;
|
||||
}
|
||||
if (!form.name.trim()) { toast.error('请输入角色名称'); return; }
|
||||
if (!form.code.trim()) { toast.error('请输入角色编码'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -149,7 +160,11 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async ( role: any) => {
|
||||
const handleDelete = async (role: any) => {
|
||||
if (role.isSystem) {
|
||||
toast.error('系统默认角色不可删除');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: '删除角色',
|
||||
description: `确认删除角色「${role.name}」吗?该操作不可恢复。`,
|
||||
@@ -162,16 +177,17 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
toast.success('角色已删除');
|
||||
await loadRoles();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '删除失败');
|
||||
const msg = err?.response?.data?.message || err?.message || '删除失败';
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{roles.length} 个角色</span>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建角色
|
||||
<Plus className="h-4 w-4 mr-2" />新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -180,77 +196,83 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-500">
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<Shield className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">暂无角色数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{roles.map(( role: any) => (
|
||||
<Card key={role.id} className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-purple-400" />
|
||||
{roles.map((role: any) => {
|
||||
const perms = role.permissions as string[];
|
||||
const codes = role.permissionCodes as string[];
|
||||
const groupedPerms = groupPermNames(perms, codes);
|
||||
const icon = ROLE_ICONS[role.code] || '🛡️';
|
||||
return (
|
||||
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${!role.isSystem ? '' : 'border-purple-500/20'}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center text-lg ${
|
||||
role.isSystem ? 'bg-purple-500/10' : 'bg-[var(--dg-bg-tertiary)]'
|
||||
}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)] flex items-center gap-1.5">
|
||||
{role.name}
|
||||
{role.isSystem && (
|
||||
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px] px-1 py-0">
|
||||
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[var(--dg-text-muted)] text-xs">{role.code}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
|
||||
<CardDescription className="text-slate-500">{role.code}</CardDescription>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setSelectedRole(role)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-blue-400"
|
||||
onClick={() => openEdit(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-red-400"
|
||||
onClick={() => handleDelete(role)}
|
||||
disabled={role.isSystem}
|
||||
title={role.isSystem ? '系统默认角色不可删除' : '删除角色'}
|
||||
>
|
||||
<Trash2 className={`h-4 w-4 ${role.isSystem ? 'opacity-30' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setSelectedRole(role)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||
onClick={() => openEdit(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={() => handleDelete(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{role.description || '暂无描述'}</p>
|
||||
{/* 按模块显示权限数 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{groupedPerms.map(g => (
|
||||
<Badge key={g.label} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] text-[10px]">
|
||||
{g.label}·{g.count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
||||
{/* 权限按模块分组显示 // Logan.Li */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(role.permissions as string[]).slice(0, 8).map((permission, idx) => (
|
||||
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||
{permission}
|
||||
</Badge>
|
||||
))}
|
||||
{(role.permissions as string[]).length > 8 && (
|
||||
<Badge variant="outline" className="border-blue-500/30 text-blue-400 text-xs">
|
||||
+{(role.permissions as string[]).length - 8} 项
|
||||
</Badge>
|
||||
{perms.length === 0 && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">未分配权限</span>
|
||||
)}
|
||||
{(role.permissions as string[]).length === 0 && (
|
||||
<span className="text-xs text-slate-600">未分配权限</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-600">
|
||||
共 {role.permissions?.length || 0} 项权限
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -259,14 +281,9 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
|
||||
{/* 新建 / 编辑角色 Dialog */}
|
||||
<RoleFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editingRole={editingRole}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
saving={saving}
|
||||
onSubmit={handleSubmit}
|
||||
permissions={permissions}
|
||||
open={dialogOpen} onOpenChange={setDialogOpen}
|
||||
editingRole={editingRole} form={form} setForm={setForm}
|
||||
saving={saving} onSubmit={handleSubmit} permissions={permissions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -274,50 +291,50 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
||||
|
||||
// 角色详情查看 Dialog
|
||||
function RoleDetailDialog({
|
||||
role,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: {
|
||||
role: Role | null;
|
||||
onClose: () => void;
|
||||
onEdit: (role: Role) => void;
|
||||
}) {
|
||||
role, onClose, onEdit,
|
||||
}: { role: Role | null; onClose: () => void; onEdit: (role: Role) => void }) {
|
||||
const rawRole = role as any;
|
||||
const isSystem = rawRole?.isSystem ?? false;
|
||||
return (
|
||||
<Dialog open={!!role} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800">
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-purple-400" />
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-purple-400" />
|
||||
{role?.name}
|
||||
{isSystem && (
|
||||
<Badge className="bg-purple-500/15 text-purple-400 border-purple-500/30 text-[10px]">
|
||||
<Lock className="h-2.5 w-2.5 mr-0.5" />系统
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-500">编码:{role?.code}</DialogDescription>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">编码:{role?.code}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-slate-800/50 p-4">
|
||||
<p className="text-xs text-slate-500 mb-1">描述</p>
|
||||
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-4">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">描述</p>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{role?.description || '暂无描述'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表 ({role?.permissions?.length || 0} 项)</h4>
|
||||
{role && role.permissions.length > 0 ? (
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">
|
||||
权限列表 ({role?.permissions?.length || 0} 项)
|
||||
</h4>
|
||||
{role && (role.permissions as string[]).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto">
|
||||
{role.permissions.map((p: string, idx: number) => (
|
||||
<Badge key={idx} variant="outline" className="border-slate-700 text-slate-300">
|
||||
{(role.permissions as string[]).map((p, idx) => (
|
||||
<Badge key={idx} variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">未分配权限</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">未分配权限</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { UserFormDialog, type UserFormData } from './UserFormDialog';
|
||||
import { UserDetailDialog } from './UserDetailDialog';
|
||||
import { CsvImportDialog } from './CsvImportDialog';
|
||||
import { LdapImportDialog } from './LdapImportDialog';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
interface SystemUserTabProps {
|
||||
// Search
|
||||
@@ -108,6 +109,7 @@ export function SystemUserTab({
|
||||
handleLdapImport,
|
||||
setLdapUsers,
|
||||
}: SystemUserTabProps) {
|
||||
const { hasPermission } = usePermission();
|
||||
return (
|
||||
<>
|
||||
{/* Filters */}
|
||||
@@ -122,18 +124,24 @@ export function SystemUserTab({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPermission('system:user') && (
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('csv')}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
批量导入
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('system:user') && (
|
||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800" onClick={() => setImportDialogOpen('ldap')}>
|
||||
<Network className="h-4 w-4 mr-2" />
|
||||
LDAP 导入
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('system:user') && (
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建用户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,6 +240,7 @@ export function SystemUserTab({
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasPermission('system:user') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -240,6 +249,8 @@ export function SystemUserTab({
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('system:user') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -248,6 +259,7 @@ export function SystemUserTab({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* usePermission — 前端 RBAC 按钮级权限控制 Hook
|
||||
*
|
||||
* 设计:
|
||||
* - 权限码存储在 zustand user store 的 `permissions` 字段
|
||||
* - admin 用户 permissions = ["*"],所有 hasPermission 返回 true
|
||||
* - 普通用户精确匹配权限码
|
||||
* - 权限未加载(null)时默认放行(避免页面空白闪烁),加载后过滤
|
||||
*
|
||||
* 用法:
|
||||
* const { hasPermission, hasAnyPermission } = usePermission();
|
||||
* if (hasPermission('server:create')) { ... }
|
||||
*
|
||||
* 或用 <HasPermission> 组件包裹:
|
||||
* <HasPermission permission="server:delete">
|
||||
* <Button>删除</Button>
|
||||
* </HasPermission>
|
||||
*/
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
// 权限通配符 — admin 用户拥有
|
||||
const WILDCARD = '*';
|
||||
|
||||
export function usePermission() {
|
||||
const permissions = useUserStore((s) => s.permissions);
|
||||
|
||||
// admin 通配符短路
|
||||
const isAdmin = permissions?.includes(WILDCARD) ?? false;
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
* @param code 权限码,如 'server:create'
|
||||
* @returns true=有权限;permissions 未加载时默认 true
|
||||
*/
|
||||
const hasPermission = (code: string): boolean => {
|
||||
// 权限未加载(登录后未调用 setPermissions)→ 默认放行
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return permissions.includes(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否拥有任意一个权限(OR 逻辑)
|
||||
*/
|
||||
const hasAnyPermission = (codes: string[]): boolean => {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return codes.some((c) => permissions.includes(c));
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否拥有全部权限(AND 逻辑)
|
||||
*/
|
||||
const hasAllPermissions = (codes: string[]): boolean => {
|
||||
if (permissions === null || permissions === undefined) return true;
|
||||
if (isAdmin) return true;
|
||||
return codes.every((c) => permissions.includes(c));
|
||||
};
|
||||
|
||||
return { hasPermission, hasAnyPermission, hasAllPermissions, isAdmin };
|
||||
}
|
||||
@@ -12,35 +12,30 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2 } from 'lucide-react';
|
||||
import { Plus, Search, RefreshCw, Clock, CheckCircle, XCircle, FileText, Loader2, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
|
||||
import { approvalApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FormField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select' | 'textarea';
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
defaultValue?: string | number;
|
||||
required?: boolean;
|
||||
key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea';
|
||||
options?: string[]; placeholder?: string; defaultValue?: string | number; required?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
category: 'resource' | 'app' | 'permission';
|
||||
title: string;
|
||||
description: string;
|
||||
fields: FormField[];
|
||||
templateCode: string;
|
||||
title: string; description: string; fields: FormField[]; templateCode: string;
|
||||
}
|
||||
|
||||
interface StepItem {
|
||||
id: number; step: number; step_type?: string; approver: number; status: string; comment: string;
|
||||
approved_at: string | null; approver_user?: { username?: string; real_name?: string };
|
||||
}
|
||||
|
||||
interface ProcessItem {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
form_data: string;
|
||||
created_at: string;
|
||||
id: number; title: string; status: string; form_data: string; created_at: string;
|
||||
applicant: number; applicant_user?: { username?: string; real_name?: string };
|
||||
template?: { name?: string }; current_step?: number; total_steps?: number;
|
||||
steps?: StepItem[];
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
@@ -51,6 +46,25 @@ const statusBadge = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
function parseFormData(data: string): Record<string, string> {
|
||||
try { return JSON.parse(data) || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function getApplicant(p: any): string {
|
||||
const u = p?.applicant_user;
|
||||
return u ? (u.real_name || u.username || `#${p.applicant}`) : (p?.applicant ? `#${p.applicant}` : '--');
|
||||
}
|
||||
|
||||
function getCurrentApprover(p: ProcessItem): string {
|
||||
if (p.status !== 'pending') return '--';
|
||||
const steps = p.steps || [];
|
||||
if (steps.length === 0) return '管理员(待分配)';
|
||||
const cs = steps.find(s => s.step === p.current_step && s.status === 'pending');
|
||||
if (!cs) return '管理员(待分配)';
|
||||
if (cs.approver_user) return cs.approver_user.real_name || cs.approver_user.username || `#${cs.approver}`;
|
||||
return '管理员';
|
||||
}
|
||||
|
||||
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
|
||||
const [processes, setProcesses] = useState<ProcessItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -60,20 +74,19 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [detailItem, setDetailItem] = useState<ProcessItem | null>(null);
|
||||
const [acting, setActing] = useState(false);
|
||||
|
||||
const fetchProcesses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page_size: '100' };
|
||||
const params: any = { page_size: '100' };
|
||||
if (category) params.category = category;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const resp = (await approvalApi.getProcesses(params)) as any;
|
||||
setProcesses(resp?.list ?? resp?.data?.list ?? []);
|
||||
} catch {
|
||||
setProcesses([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch { setProcesses([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [category, statusFilter]);
|
||||
|
||||
const fetchTemplateId = useCallback(async () => {
|
||||
@@ -81,7 +94,7 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
const resp = (await approvalApi.getTemplates({ type: templateCode })) as any;
|
||||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||||
if (list.length > 0) setTemplateId(list[0].id);
|
||||
} catch { /* ignore */ }
|
||||
} catch {}
|
||||
}, [templateCode]);
|
||||
|
||||
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
|
||||
@@ -89,50 +102,56 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!templateId) { toast.error('模板未加载'); return; }
|
||||
// Validate required fields
|
||||
for (const f of fields) {
|
||||
if (f.required && !formData[f.key]) {
|
||||
toast.error(`请填写「${f.label}」`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const f of fields) { if (f.required && !formData[f.key]) { toast.error(`请填写「${f.label}」`); return; } }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Build title from first 2 fields
|
||||
const firstVal = formData[fields[0]?.key] || '';
|
||||
const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
|
||||
const autoTitle = `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`;
|
||||
|
||||
await approvalApi.createProcess({
|
||||
template_id: templateId,
|
||||
title: autoTitle,
|
||||
title: `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`,
|
||||
form_data: JSON.stringify(formData),
|
||||
});
|
||||
toast.success('申请已提交');
|
||||
setDialogOpen(false);
|
||||
setFormData({});
|
||||
fetchProcesses();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
setDialogOpen(false); setFormData({}); fetchProcesses();
|
||||
} catch (err: any) { toast.error(err?.message || '提交失败'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const openCreateDialog = () => {
|
||||
const init: Record<string, string> = {};
|
||||
fields.forEach((f) => { init[f.key] = f.defaultValue?.toString() || ''; });
|
||||
setFormData(init);
|
||||
setDialogOpen(true);
|
||||
setFormData(init); setDialogOpen(true);
|
||||
};
|
||||
|
||||
const filtered = processes.filter((p) => {
|
||||
return !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase());
|
||||
});
|
||||
const handleApprove = async (id: number) => {
|
||||
setActing(true);
|
||||
try { await approvalApi.approveProcess(id); toast.success('已通过'); fetchProcesses(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message || '审批失败'); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const handleReject = async (id: number) => {
|
||||
setActing(true);
|
||||
try { await approvalApi.rejectProcess(id, '审批拒绝'); toast.success('已拒绝'); fetchProcesses(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message || '操作失败'); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const openDetail = async (p: ProcessItem) => {
|
||||
// Load full detail including steps
|
||||
try {
|
||||
const resp: any = await approvalApi.getProcesses({ page_size: 200 } as any);
|
||||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||||
const full = list.find((x: any) => x.id === p.id);
|
||||
setDetailItem(full || p);
|
||||
} catch { setDetailItem(p); }
|
||||
};
|
||||
|
||||
const filtered = processes.filter((p) => !keyword || (p.title || '').toLowerCase().includes(keyword.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1>
|
||||
@@ -143,16 +162,11 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input
|
||||
placeholder="搜索申请..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input placeholder="搜索申请..." value={keyword} onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
@@ -170,13 +184,10 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
|
||||
<FileText className="h-10 w-10 mb-3 opacity-30" />
|
||||
@@ -187,21 +198,40 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">ID</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">申请标题</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">提交时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">提交账号</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">当前审核人</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] w-28">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((p) => (
|
||||
<TableRow key={p.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-xs">#{p.id}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-primary)] font-medium">{p.title}</TableCell>
|
||||
<TableCell>{statusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-sm">
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-sm whitespace-nowrap">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN') : '--'}
|
||||
</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-primary)]">{getApplicant(p)}</TableCell>
|
||||
<TableCell className="text-[var(--dg-text-muted)] text-sm">{getCurrentApprover(p)}</TableCell>
|
||||
<TableCell>{statusBadge(p.status)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{p.status === 'pending' ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
|
||||
<XIcon className="h-3.5 w-3.5 mr-1" />拒绝
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />通过
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]" onClick={() => openDetail(p)}>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />详情
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -220,54 +250,233 @@ export function ApprovalCategoryPage({ category, title, description, fields, tem
|
||||
<div className="space-y-4 py-2">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-primary)] text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-400 ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Label className="text-[var(--dg-text-primary)] text-sm">{field.label}{field.required && <span className="text-red-400 ml-0.5">*</span>}</Label>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
value={formData[field.key] || ''}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder={field.placeholder || `选择${field.label}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options || []).map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
<Select value={formData[field.key] || ''} onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"><SelectValue placeholder={field.placeholder || `选择${field.label}`} /></SelectTrigger>
|
||||
<SelectContent>{(field.options || []).map((opt) => (<SelectItem key={opt} value={opt}>{opt}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]"
|
||||
/>
|
||||
<textarea value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder} rows={3}
|
||||
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] px-3 py-2 text-sm resize-none focus:outline-none focus:border-[var(--dg-blue)]" />
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
value={formData[field.key] || ''}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input type={field.type} value={formData[field.key] || ''} onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.placeholder} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
{submitting ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />提交中...</> : '提交申请'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Detail Dialog — operation timeline */}
|
||||
<DetailTimelineDialog item={detailItem} onClose={() => setDetailItem(null)} fields={fields} onStatusChange={fetchProcesses} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DetailTimelineDialog — 操作时间线 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
|
||||
item: ProcessItem | null; onClose: () => void; fields: FormField[]; onStatusChange: () => void;
|
||||
}) {
|
||||
const [acting, setActing] = useState(false);
|
||||
if (!item) return null;
|
||||
|
||||
const timeStr = (t: string | null) => {
|
||||
if (!t) return '';
|
||||
return new Date(t).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const steps = item.steps || [];
|
||||
|
||||
const handleApprove = async () => {
|
||||
setActing(true);
|
||||
try { await approvalApi.approveProcess(item.id); toast.success('已通过'); onStatusChange(); onClose(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message || '审批失败'); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setActing(true);
|
||||
try { await approvalApi.rejectProcess(item.id, '审批拒绝'); toast.success('已拒绝'); onStatusChange(); onClose(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message || '操作失败'); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const stepStatusLabel = (s: StepItem) => {
|
||||
if (s.status === 'approved') return '通过';
|
||||
if (s.status === 'rejected') return '拒绝';
|
||||
if (s.status === 'pending' && s.step === item.current_step) return '待审批';
|
||||
if (s.status === 'pending') return '待开始';
|
||||
return s.status;
|
||||
};
|
||||
|
||||
const stepStatusColor = (s: StepItem) => {
|
||||
if (s.status === 'approved') return 'text-emerald-400';
|
||||
if (s.status === 'rejected') return 'text-red-400';
|
||||
if (s.step === item.current_step) return 'text-blue-400';
|
||||
return 'text-[var(--dg-text-muted)]';
|
||||
};
|
||||
|
||||
const approverName = (s: StepItem) => {
|
||||
if (s.approver_user) return s.approver_user.real_name || s.approver_user.username || `#${s.approver}`;
|
||||
return '管理员';
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!item} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">申请详情</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">{item.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Status + Meta */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">状态:</span>
|
||||
{statusBadge(item.status)}
|
||||
{item.status === 'pending' && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
第 {item.current_step}/{item.total_steps} 步
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operation Timeline */}
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-4">操作时间线</h4>
|
||||
<div className="space-y-0">
|
||||
{/* 1. Submit node */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-7 h-7 rounded-full bg-blue-500/20 flex items-center justify-center"><User className="w-3.5 h-3.5 text-blue-400" /></div>
|
||||
<div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">提交申请</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{getApplicant(item)}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{timeStr(item.created_at)}</span>
|
||||
</div>
|
||||
{/* Form data summary */}
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
const data = parseFormData(item.form_data || '{}');
|
||||
return fields.slice(0, 4).map((f) => {
|
||||
const val = data[f.key];
|
||||
if (!val) return null;
|
||||
return (
|
||||
<span key={f.key} className="text-[11px] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
||||
{f.label}: <span className="text-[var(--dg-text-secondary)]">{val}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Approval / Execution steps */}
|
||||
{steps.map((s, i) => {
|
||||
const isLast = i === steps.length - 1;
|
||||
const isExecute = s.step_type === 'execute';
|
||||
const stepLabel = isExecute ? '执行' : '审核';
|
||||
const iconColor = s.status === 'approved' ? 'bg-emerald-500/20'
|
||||
: s.status === 'rejected' ? 'bg-red-500/20'
|
||||
: 'bg-[var(--dg-bg-tertiary)]';
|
||||
return (
|
||||
<div key={s.id} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center ${iconColor}`}>
|
||||
{s.status === 'approved' ? <Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 text-red-400" />
|
||||
: isExecute
|
||||
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
|
||||
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />}
|
||||
</div>
|
||||
{!isLast && <div className="w-0.5 flex-1 bg-[var(--dg-border)] my-1" />}
|
||||
</div>
|
||||
<div className={`flex-1 ${!isLast ? 'pb-4' : ''}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{stepLabel}</span>
|
||||
{s.approver_user && (
|
||||
<>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">指派人: {approverName(s)}</span>
|
||||
</>
|
||||
)}
|
||||
{s.approved_at && (
|
||||
<>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||||
<span className={`text-xs ${stepStatusColor(s)}`}>
|
||||
{approverName(s)} · {stepStatusLabel(s)} · {timeStr(s.approved_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!s.approved_at && (
|
||||
<>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">·</span>
|
||||
<span className={`text-xs ${stepStatusColor(s)}`}>{stepStatusLabel(s)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{s.comment && s.comment !== s.status && s.comment !== 'pending' && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1 ml-0">{s.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full form data */}
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<h4 className="text-sm font-medium text-[var(--dg-text-primary)] mb-2">申请内容</h4>
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const data = parseFormData(item.form_data || '{}');
|
||||
return fields.map((f) => {
|
||||
const val = data[f.key];
|
||||
return (
|
||||
<div key={f.key} className="grid grid-cols-3 gap-2 text-sm">
|
||||
<span className="text-[var(--dg-text-muted)]">{f.label}:</span>
|
||||
<span className="text-[var(--dg-text-primary)] col-span-2">{val ?? '--'}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
{item.status === 'pending' && (
|
||||
<>
|
||||
<Button variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
|
||||
<XIcon className="h-4 w-4 mr-1.5" />拒绝
|
||||
</Button>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleApprove} disabled={acting}>
|
||||
<Check className="h-4 w-4 mr-1.5" />通过
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>关闭</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
// 审批流程配置 — 参考 OpsxSQL WorkflowPage 工作流模式
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Plus, Edit2, Trash2, X, ChevronDown, ChevronUp, ArrowUp, ArrowDown,
|
||||
Play, Loader2, Star, CheckCircle, GitBranch, Users, Shield,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface WorkflowStep {
|
||||
id?: number;
|
||||
workflow_id?: number;
|
||||
step_number: number;
|
||||
name: string;
|
||||
step_type: string;
|
||||
approver_ids: string | null;
|
||||
is_required: boolean;
|
||||
timeout_hours: number;
|
||||
auto_approve_on_timeout: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
interface StepDraft {
|
||||
name: string;
|
||||
step_type: 'submit' | 'audit' | 'execute';
|
||||
approver_ids: number[];
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
resource: '资源申请',
|
||||
app: '应用申请',
|
||||
permission: '权限审批',
|
||||
};
|
||||
|
||||
const STEP_TYPE_LABELS: Record<string, string> = {
|
||||
submit: '提交阶段',
|
||||
audit: '审核阶段',
|
||||
execute: '执行阶段',
|
||||
};
|
||||
|
||||
const STEP_TYPE_COLORS: Record<string, string> = {
|
||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
||||
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
|
||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
||||
};
|
||||
|
||||
const STEP_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
submit: Users,
|
||||
audit: Shield,
|
||||
execute: Play,
|
||||
};
|
||||
|
||||
const STEP_TYPES = [
|
||||
{ value: 'submit' as const, label: '提交阶段', desc: '工单提交人' },
|
||||
{ value: 'audit' as const, label: '审核阶段', desc: '审核人员审批' },
|
||||
{ value: 'execute' as const, label: '执行阶段', desc: '执行人员操作' },
|
||||
];
|
||||
|
||||
// API helpers
|
||||
const workflowApi = {
|
||||
list: (params?: { category?: string }) => http.get('/approval-workflows', params),
|
||||
get: (id: number) => http.get(`/approval-workflows/${id}`),
|
||||
create: (data: any) => http.post('/approval-workflows', data),
|
||||
update: (id: number, data: any) => http.put(`/approval-workflows/${id}`, data),
|
||||
delete: (id: number) => http.delete(`/approval-workflows/${id}`),
|
||||
setDefault: (id: number) => http.put(`/approval-workflows/${id}/default`),
|
||||
};
|
||||
const userListApi = () => http.get('/admin/users');
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* UserSelectInline */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function UserSelectInline({
|
||||
users, selectedIds, label, onToggle, onToggleAll, onClear, getUserName,
|
||||
}: {
|
||||
users: UserItem[];
|
||||
selectedIds: number[];
|
||||
label: string;
|
||||
onToggle: (uid: number) => void;
|
||||
onToggleAll: () => void;
|
||||
onClear: () => void;
|
||||
getUserName: (uid: number) => string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
|
||||
|
||||
const updatePos = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => { if (!open) updatePos(); setOpen(!open); }}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedIds.length === 0
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-red-500/40 text-[var(--dg-text-muted)]'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedIds.length === 0 ? `${label} * 请选择` : `${label} (${selectedIds.length}人)`}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{selectedIds.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{selectedIds.map((uid) => (
|
||||
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
<button onClick={() => onToggle(uid)} className="hover:text-red-400"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="fixed z-50 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg shadow-xl max-h-52 overflow-y-auto"
|
||||
style={{ top: pos.top, left: pos.left, width: pos.width }}>
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[var(--dg-border)]">
|
||||
<button onClick={onToggleAll} className="text-[11px] text-blue-400 hover:text-blue-300">
|
||||
{selectedIds.length === users.length && users.length > 0 ? '取消全选' : '全选'}
|
||||
</button>
|
||||
{selectedIds.length > 0 && (
|
||||
<button onClick={onClear} className="text-[11px] text-red-400 hover:text-red-300">清空</button>
|
||||
)}
|
||||
</div>
|
||||
{users.map((u) => {
|
||||
const checked = selectedIds.includes(u.id);
|
||||
return (
|
||||
<div key={u.id} onClick={() => onToggle(u.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||
checked ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
|
||||
}`}>
|
||||
{checked && <CheckCircle className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm flex-1 ${checked ? 'text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-secondary)]'}`}>
|
||||
{u.display_name || u.username}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] px-1.5 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">
|
||||
{u.role}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* WorkflowEditorModal */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface EditorProps {
|
||||
workflow?: WorkflowItem | null;
|
||||
users: UserItem[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function WorkflowEditorModal({ workflow, users, onClose, onSaved }: EditorProps) {
|
||||
const isEdit = !!workflow;
|
||||
const [name, setName] = useState(workflow?.name || '');
|
||||
const [description, setDescription] = useState(workflow?.description || '');
|
||||
const [category, setCategory] = useState(workflow?.category || 'resource');
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (workflow?.steps?.length) {
|
||||
setSteps(
|
||||
workflow.steps.map((s) => ({
|
||||
name: s.name,
|
||||
step_type: (s.step_type === 'serial' || s.step_type === 'parallel')
|
||||
? ('audit' as const)
|
||||
: (s.step_type as 'submit' | 'audit' | 'execute'),
|
||||
approver_ids: parseIds(s.approver_ids),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setSteps([
|
||||
{ name: '提交申请', step_type: 'submit', approver_ids: [] },
|
||||
{ name: '审核', step_type: 'audit', approver_ids: [] },
|
||||
]);
|
||||
}
|
||||
}, [workflow]);
|
||||
|
||||
const parseIds = (ids: string | null): number[] => {
|
||||
if (!ids) return [];
|
||||
try {
|
||||
const p = JSON.parse(ids);
|
||||
return Array.isArray(p) ? p : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const addStep = (type: 'submit' | 'audit' | 'execute') => {
|
||||
const labels: Record<string, string> = { submit: '提交', audit: '审核', execute: '执行' };
|
||||
const count = steps.filter((s) => s.step_type === type).length;
|
||||
const suffix = count > 0 ? ` ${count + 1}` : '';
|
||||
setSteps([...steps, { name: `${labels[type]}${suffix}`, step_type: type, approver_ids: [] }]);
|
||||
};
|
||||
|
||||
const removeStep = (idx: number) => setSteps(steps.filter((_, i) => i !== idx));
|
||||
const moveStep = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
const next = [...steps];
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
setSteps(next);
|
||||
};
|
||||
const updateStep = (idx: number, patch: Partial<StepDraft>) => {
|
||||
setSteps(steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const toggleUser = (idx: number, uid: number) => {
|
||||
const step = steps[idx];
|
||||
const has = step.approver_ids.includes(uid);
|
||||
updateStep(idx, { approver_ids: has ? step.approver_ids.filter((id) => id !== uid) : [...step.approver_ids, uid] });
|
||||
};
|
||||
|
||||
const getEligibleUsers = (stepType: string) => {
|
||||
const active = users.filter(u => u.is_active !== false);
|
||||
if (stepType === 'audit') return active;
|
||||
if (stepType === 'execute') return active.filter(u =>
|
||||
['super_admin', 'k8s_admin', 'release_admin', 'resource_admin'].includes(u.role)
|
||||
);
|
||||
return active;
|
||||
};
|
||||
|
||||
const toggleAllUsers = (idx: number) => {
|
||||
const step = steps[idx];
|
||||
const eligible = getEligibleUsers(step.step_type);
|
||||
const allSelected = step.approver_ids.length === eligible.length && eligible.length > 0;
|
||||
updateStep(idx, { approver_ids: allSelected ? [] : eligible.map((u) => u.id) });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { setError('请输入流程名称'); return; }
|
||||
if (steps.length === 0) { setError('请至少添加一个步骤'); return; }
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) {
|
||||
setError(`步骤"${steps[i].name}"需要关联至少一个审批人`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
category,
|
||||
steps: steps.map((s, i) => ({
|
||||
step_number: i + 1,
|
||||
name: s.name,
|
||||
step_type: s.step_type,
|
||||
approver_ids: s.approver_ids.length > 0 ? JSON.stringify(s.approver_ids) : null,
|
||||
is_required: true,
|
||||
timeout_hours: 24,
|
||||
auto_approve_on_timeout: false,
|
||||
})),
|
||||
};
|
||||
if (isEdit && workflow) {
|
||||
await workflowApi.update(workflow.id, payload);
|
||||
} else {
|
||||
await workflowApi.create(payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserName = (uid: number) => {
|
||||
const u = users.find((u) => u.id === uid);
|
||||
return u ? (u.display_name || u.username) : `#${uid}`;
|
||||
};
|
||||
|
||||
const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors text-[var(--dg-text-primary)]';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--dg-border)]">
|
||||
<h2 className="text-base font-semibold text-[var(--dg-text-primary)]">
|
||||
{isEdit ? '编辑审批流程' : '新建审批流程'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-5 space-y-4 pt-4">
|
||||
{error && <div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">流程名称 *</label>
|
||||
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} placeholder="输入流程名称" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">所属分类</label>
|
||||
<select className={inputCls} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="resource">资源申请</option>
|
||||
<option value="app">应用申请</option>
|
||||
<option value="permission">权限审批</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
|
||||
<input className={inputCls} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-[var(--dg-text-secondary)]">流程步骤</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{STEP_TYPES.map((t) => (
|
||||
<button key={t.value} onClick={() => addStep(t.value)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 rounded transition-colors">
|
||||
<Plus className="w-3 h-3" />{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => {
|
||||
const colorCls = STEP_TYPE_COLORS[step.step_type];
|
||||
const Icon = STEP_TYPE_ICONS[step.step_type] || Shield;
|
||||
return (
|
||||
<div key={idx} className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${colorCls.split(' ')[0]}`}>
|
||||
<Icon className={`w-3.5 h-3.5 ${colorCls.split(' ')[1]}`} />
|
||||
</div>
|
||||
<input
|
||||
className="flex-1 px-2 py-1 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-sm focus:border-blue-400 focus:outline-none text-[var(--dg-text-primary)]"
|
||||
value={step.name} onChange={(e) => updateStep(idx, { name: e.target.value })}
|
||||
placeholder="步骤名称"
|
||||
/>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colorCls}`}>
|
||||
{STEP_TYPE_LABELS[step.step_type]}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 ml-1">
|
||||
<button onClick={() => moveStep(idx, -1)} disabled={idx === 0}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => moveStep(idx, 1)} disabled={idx === steps.length - 1}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] disabled:opacity-20 transition-colors">
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => removeStep(idx)}
|
||||
className="p-1 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.step_type !== 'submit' && (
|
||||
<div className="pl-9 mt-2">
|
||||
<UserSelectInline
|
||||
users={getEligibleUsers(step.step_type)}
|
||||
selectedIds={step.approver_ids}
|
||||
label={step.step_type === 'audit' ? '审核人' : '执行人'}
|
||||
onToggle={(uid) => toggleUser(idx, uid)}
|
||||
onToggleAll={() => toggleAllUsers(idx)}
|
||||
onClear={() => updateStep(idx, { approver_ids: [] })}
|
||||
getUserName={getUserName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{steps.length === 0 && (
|
||||
<div className="text-center py-4 text-sm text-[var(--dg-text-muted)]">
|
||||
请点击上方按钮添加流程步骤
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[var(--dg-border)]">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">
|
||||
取消
|
||||
</button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin mr-1" />}
|
||||
{isEdit ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function ApprovalWorkflowPage() {
|
||||
const [items, setItems] = useState<WorkflowItem[]>([]);
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [editorWorkflow, setEditorWorkflow] = useState<WorkflowItem | null | undefined>(undefined);
|
||||
|
||||
const fetchWorkflows = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res: any = await workflowApi.list();
|
||||
const data = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : []);
|
||||
const normalized = data.map((w: any) => ({ ...w, steps: w.steps || [] }));
|
||||
setItems(normalized);
|
||||
} catch {
|
||||
setError('获取审批流程失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res: any = await userListApi();
|
||||
const data = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : []);
|
||||
// Normalize: extract role code from roles array
|
||||
const normalized = data.map((u: any) => ({
|
||||
...u,
|
||||
role: u.role || (Array.isArray(u.roles) && u.roles.length > 0 ? u.roles[0].code : ''),
|
||||
}));
|
||||
setUsers(normalized);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflows();
|
||||
fetchUsers();
|
||||
}, [fetchWorkflows, fetchUsers]);
|
||||
|
||||
const toggleActive = useCallback(async (wf: WorkflowItem) => {
|
||||
try {
|
||||
await workflowApi.update(wf.id, { is_active: !wf.is_active });
|
||||
setItems((prev) => prev.map((w) => (w.id === wf.id ? { ...w, is_active: !w.is_active } : w)));
|
||||
toast.success(wf.is_active ? '已停用' : '已启用');
|
||||
} catch { toast.error('操作失败'); }
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(async (id: number, name: string) => {
|
||||
if (!window.confirm(`确定删除审批流程「${name}」?删除后不可恢复。`)) return;
|
||||
try {
|
||||
await workflowApi.delete(id);
|
||||
setItems((prev) => prev.filter((w) => w.id !== id));
|
||||
toast.success('已删除');
|
||||
} catch { toast.error('删除失败'); }
|
||||
}, []);
|
||||
|
||||
const setDefault = useCallback(async (id: number) => {
|
||||
try {
|
||||
await workflowApi.setDefault(id);
|
||||
setItems((prev) => prev.map((w) => ({ ...w, is_default: w.id === id })));
|
||||
toast.success('已设为默认');
|
||||
} catch { toast.error('操作失败'); }
|
||||
}, []);
|
||||
|
||||
const getUserName = useCallback(
|
||||
(uid: number) => {
|
||||
const u = users.find((u) => u.id === uid);
|
||||
return u ? (u.display_name || u.username) : `#${uid}`;
|
||||
},
|
||||
[users]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--dg-text-muted)]">
|
||||
<p className="text-sm">{error}</p>
|
||||
<Button variant="outline" onClick={fetchWorkflows} className="mt-3">重试</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--dg-text-primary)]">审批流程配置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
为资源申请、应用申请、权限审批配置多级审批流程
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditorWorkflow(null)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />新建流程
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="dg-card p-12 text-center">
|
||||
<GitBranch className="w-12 h-12 mx-auto mb-3 opacity-50 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">暂无审批流程</p>
|
||||
<Button variant="outline" onClick={() => setEditorWorkflow(null)}>创建第一个流程</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Workflow list */}
|
||||
<div className="space-y-4">
|
||||
{items.map((wf) => {
|
||||
const isExpanded = expandedId === wf.id;
|
||||
return (
|
||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.is_active ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold text-[var(--dg-text-primary)]">{wf.name}</h3>
|
||||
{wf.is_default && <span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-400/15 text-yellow-400 flex items-center gap-1"><Star className="w-3 h-3" />默认</span>}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${wf.is_active ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
||||
{wf.is_active ? '启用' : '停用'}
|
||||
</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">
|
||||
{CATEGORY_LABELS[wf.category] || wf.category}
|
||||
</span>
|
||||
</div>
|
||||
{wf.description && <p className="text-sm text-[var(--dg-text-secondary)] mb-1">{wf.description}</p>}
|
||||
<div className="flex items-center gap-1.5 mt-2 flex-wrap">
|
||||
{wf.steps?.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
|
||||
{STEP_TYPE_LABELS[s.step_type] || s.step_type}
|
||||
</span>
|
||||
{i < (wf.steps?.length || 0) - 1 && (
|
||||
<span className="text-[var(--dg-text-muted)] text-[10px]">→</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-4">
|
||||
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] transition-colors">
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
{!wf.is_default && (
|
||||
<button onClick={() => setDefault(wf.id)}
|
||||
className="p-1.5 rounded hover:bg-yellow-500/10 text-[var(--dg-text-muted)] hover:text-yellow-400 transition-colors" title="设为默认">
|
||||
<Star className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => toggleActive(wf)}
|
||||
className={`p-1.5 rounded transition-colors ${wf.is_active ? 'hover:bg-red-500/10 text-green-400 hover:text-red-400' : 'hover:bg-green-500/10 text-red-400 hover:text-green-400'}`}
|
||||
title={wf.is_active ? '停用' : '启用'}>
|
||||
<Play className={`w-4 h-4 ${!wf.is_active ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setEditorWorkflow(wf)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(wf.id, wf.name)}
|
||||
className="p-1.5 rounded hover:bg-red-500/10 text-[var(--dg-text-muted)] hover:text-red-400 transition-colors" title="删除">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--dg-border)] space-y-3">
|
||||
{wf.steps?.map((s, i) => {
|
||||
const ids = (() => { try { const p = JSON.parse(s.approver_ids || '[]'); return Array.isArray(p) ? p : []; } catch { return []; } })();
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg">
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--dg-bg-tertiary)] flex items-center justify-center text-xs font-medium text-[var(--dg-text-secondary)] flex-shrink-0 mt-0.5">
|
||||
{s.step_number}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{s.name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${STEP_TYPE_COLORS[s.step_type] || 'bg-gray-500/15 text-gray-400 border-gray-500/30'}`}>
|
||||
{STEP_TYPE_LABELS[s.step_type] || s.step_type}
|
||||
</span>
|
||||
</div>
|
||||
{ids.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ids.map((uid) => (
|
||||
<span key={uid} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-blue-500/15 text-blue-400">
|
||||
{getUserName(uid)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ids.length === 0 && s.step_type !== 'submit' && (
|
||||
<span className="text-[11px] text-red-400">未关联审批人</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editorWorkflow !== undefined && (
|
||||
<WorkflowEditorModal
|
||||
workflow={editorWorkflow}
|
||||
users={users}
|
||||
onClose={() => setEditorWorkflow(undefined)}
|
||||
onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import { isOnline, timeAgo, asList } from '../../components/agent/agentUtils';
|
||||
|
||||
export const userApi = {
|
||||
login: async (username: string, password: string) => {
|
||||
return http.post<{ token: string; expires_in: number; user: unknown }>(
|
||||
return http.post<{ token: string; expires_in: number; user: unknown; permissions?: string[] }>(
|
||||
'/auth/login',
|
||||
{ username, password }
|
||||
);
|
||||
},
|
||||
getMyPermissions: async () => {
|
||||
return http.get<{ permissions: string[]; is_admin: boolean }>('/user/permissions');
|
||||
},
|
||||
register: async (data: { username: string; password: string; nickname?: string; email?: string }) => {
|
||||
return http.post('/auth/register', data);
|
||||
},
|
||||
|
||||
@@ -77,6 +77,32 @@ export const loginLogApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 审批流程配置 API(工作流定义) ====================
|
||||
|
||||
export const approvalWorkflowApi = {
|
||||
list: async (params?: { category?: string }) => {
|
||||
return http.get('/approval-workflows', params);
|
||||
},
|
||||
get: async (id: number | string) => {
|
||||
return http.get(`/approval-workflows/${id}`);
|
||||
},
|
||||
create: async (data: any) => {
|
||||
return http.post('/approval-workflows', data);
|
||||
},
|
||||
update: async (id: number | string, data: any) => {
|
||||
return http.put(`/approval-workflows/${id}`, data);
|
||||
},
|
||||
delete: async (id: number | string) => {
|
||||
return http.delete(`/approval-workflows/${id}`);
|
||||
},
|
||||
setDefault: async (id: number | string) => {
|
||||
return http.put(`/approval-workflows/${id}/default`);
|
||||
},
|
||||
getSteps: async (id: number | string) => {
|
||||
return http.get(`/approval-workflows/${id}/steps`);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 审批流程 API ====================
|
||||
|
||||
export const approvalApi = {
|
||||
|
||||
@@ -19,9 +19,12 @@ interface UserState {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
theme?: string;
|
||||
/** 当前用户权限码列表(RBAC 按钮级控制);admin 为 ["*"];null 表示未加载 */
|
||||
permissions: string[] | null;
|
||||
setUser: (user: User | null) => void;
|
||||
setToken: (token: string | null) => void;
|
||||
login: (user: User, token: string) => void;
|
||||
setPermissions: (permissions: string[] | null) => void;
|
||||
login: (user: User, token: string, permissions?: string[]) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -32,10 +35,12 @@ export const useUserStore = create<UserState>()(
|
||||
token: null as string | null,
|
||||
isAuthenticated: false,
|
||||
theme: 'ocean-deep',
|
||||
permissions: null as string[] | null,
|
||||
setUser: (user) => set({ user }),
|
||||
setToken: (token) => set({ token }),
|
||||
login: (user, token) => set({ user, token, isAuthenticated: true }),
|
||||
logout: () => { localStorage.removeItem('token'); set({ user: null as User | null, token: null as string | null, isAuthenticated: false }); },
|
||||
setPermissions: (permissions) => set({ permissions }),
|
||||
login: (user, token, permissions) => set({ user, token, isAuthenticated: true, permissions: permissions ?? null }),
|
||||
logout: () => { localStorage.removeItem('token'); set({ user: null as User | null, token: null as string | null, isAuthenticated: false, permissions: null }); },
|
||||
}),
|
||||
{
|
||||
name: 'user-storage',
|
||||
|
||||
Reference in new issue
Block a user