// 审批流程配置 — 参考 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 = { resource: '资源申请', app: '应用申请', permission: '权限审批', }; const STEP_TYPE_LABELS: Record = { submit: '提交阶段', audit: '审核阶段', execute: '执行阶段', }; const STEP_TYPE_COLORS: Record = { 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 = { 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(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 (
{selectedIds.length > 1 && (
{selectedIds.map((uid) => ( {getUserName(uid)} ))}
)} {open && ( <>
setOpen(false)} />
{selectedIds.length > 0 && ( )}
{users.map((u) => { const checked = selectedIds.includes(u.id); return (
onToggle(u.id)} className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-[var(--dg-bg-hover)] transition-colors">
{checked && }
{u.display_name || u.username} {u.role}
); })}
)}
); } /* ------------------------------------------------------------------ */ /* 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([]); 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 = { 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) => { 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 (

{isEdit ? '编辑审批流程' : '新建审批流程'}

{error &&
{error}
}
setName(e.target.value)} placeholder="输入流程名称" />
setDescription(e.target.value)} placeholder="可选" />
{/* Steps */}
{STEP_TYPES.map((t) => ( ))}
{steps.map((step, idx) => { const colorCls = STEP_TYPE_COLORS[step.step_type]; const Icon = STEP_TYPE_ICONS[step.step_type] || Shield; return (
updateStep(idx, { name: e.target.value })} placeholder="步骤名称" /> {STEP_TYPE_LABELS[step.step_type]}
{step.step_type !== 'submit' && (
toggleUser(idx, uid)} onToggleAll={() => toggleAllUsers(idx)} onClear={() => updateStep(idx, { approver_ids: [] })} getUserName={getUserName} />
)}
); })} {steps.length === 0 && (
请点击上方按钮添加流程步骤
)}
); } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ export default function ApprovalWorkflowPage() { const [items, setItems] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); const [editorWorkflow, setEditorWorkflow] = useState(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 (
); } if (error) { return (

{error}

); } return (
{/* Header */}

审批流程配置

为资源申请、应用申请、权限审批配置多级审批流程

{items.length === 0 ? (

暂无审批流程

) : ( <> {/* Workflow list */}
{items.map((wf) => { const isExpanded = expandedId === wf.id; return (

{wf.name}

{wf.is_default && 默认} {wf.is_active ? '启用' : '停用'} {CATEGORY_LABELS[wf.category] || wf.category}
{wf.description &&

{wf.description}

}
{wf.steps?.map((s, i) => ( {STEP_TYPE_LABELS[s.step_type] || s.step_type} {i < (wf.steps?.length || 0) - 1 && ( → )} ))}
{!wf.is_default && ( )}
{isExpanded && (
{wf.steps?.map((s, i) => { const ids = (() => { try { const p = JSON.parse(s.approver_ids || '[]'); return Array.isArray(p) ? p : []; } catch { return []; } })(); return (
{s.step_number}
{s.name} {STEP_TYPE_LABELS[s.step_type] || s.step_type}
{ids.length > 0 && (
{ids.map((uid) => ( {getUserName(uid)} ))}
)} {ids.length === 0 && s.step_type !== 'submit' && ( 未关联审批人 )}
); })}
)}
); })}
)} {editorWorkflow !== undefined && ( setEditorWorkflow(undefined)} onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }} /> )}
); }