// 审批流程配置 — 参考 OpsxSQL WorkflowPage 工作流模式 import { useState, useEffect, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { toast } from 'sonner'; import { approvalWorkflowApi, userApi } from '@/api'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { PageShell } from '@/components/layout/PageShell'; import { PageEmpty } from '@/components/layout/PageEmpty'; import type { JsonData } from '@/types/common'; /* ------------------------------------------------------------------ */ /* 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 STEP_TYPE_COLORS: Record = { submit: 'bg-blue-500/15 dg-text-blue border-blue-500/30', audit: 'bg-purple-500/15 dg-text-purple border-purple-500/30', execute: 'bg-green-500/15 dg-text-green border-green-500/30', }; const STEP_TYPE_ICONS: Record = { submit: Users, audit: Shield, execute: Play, }; /** 解析审批人 ID 列表 // Logan.Li */ function parseApproverIds(ids: string | null): number[] { if (!ids) return []; try { const p = JSON.parse(ids); return Array.isArray(p) ? p : []; } catch { return []; } } /** 查找 admin 用户 ID,用于新建流程默认审批/执行人 // Logan.Li */ function resolveAdminId(users: UserItem[]): number[] { const admin = users.find((u) => u.username === 'admin'); return admin ? [admin.id] : []; } /** 新建流程默认步骤:提交 → admin 审批 → admin 执行 // Logan.Li */ function buildDefaultSteps(users: UserItem[], t: (key: string) => string): StepDraft[] { const adminIds = resolveAdminId(users); return [ { name: t('approval.detail.submitNode'), step_type: 'submit', approver_ids: [] }, { name: t('approval.step.auditShort'), step_type: 'audit', approver_ids: adminIds }, { name: t('approval.step.executeShort'), step_type: 'execute', approver_ids: adminIds }, ]; } /* ------------------------------------------------------------------ */ /* 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 { t } = useTranslation(); 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 { t } = useTranslation(); 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(''); const stepTypeLabels: Record = { submit: t('approval.step.submit'), audit: t('approval.step.audit'), execute: t('approval.step.execute'), }; const stepTypes = [ { value: 'submit' as const, label: t('approval.step.submit') }, { value: 'audit' as const, label: t('approval.step.audit') }, { value: 'execute' as const, label: t('approval.step.execute') }, ]; 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: parseApproverIds(s.approver_ids), })) ); } else { setSteps(buildDefaultSteps(users, t)); } }, [workflow, users, t]); const addStep = (type: 'submit' | 'audit' | 'execute') => { const labels: Record = { submit: t('approval.step.submitShort'), audit: t('approval.step.auditShort'), execute: t('approval.step.executeShort'), }; 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(t('approval.editor.nameRequired')); return; } if (steps.length === 0) { setError(t('approval.editor.addStepRequired')); return; } for (let i = 0; i < steps.length; i++) { if (steps[i].step_type !== 'submit' && steps[i].approver_ids.length === 0) { setError(t('approval.editor.stepNeedsApprover', { name: 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 approvalWorkflowApi.update(workflow.id, payload); } else { await approvalWorkflowApi.create(payload); } onSaved(); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || t('approval.editor.saveFailed'); 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 ( !open && onClose()}> {isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')} {isEdit ? t('approval.editor.editTitle') : t('approval.editor.newTitle')}
{error &&
{error}
}
setName(e.target.value)} placeholder={t('approval.editor.enterName')} />
setDescription(e.target.value)} placeholder={t('common.optional')} />
{/* Steps */}
{stepTypes.map((tp) => ( ))}
{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={t('approval.step.name')} /> {stepTypeLabels[step.step_type]}
{step.step_type !== 'submit' && (
toggleUser(idx, uid)} onToggleAll={() => toggleAllUsers(idx)} onClear={() => updateStep(idx, { approver_ids: [] })} getUserName={getUserName} />
)}
); })} {steps.length === 0 && (
{t('approval.step.addHint')}
)}
); } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ export default function ApprovalWorkflowPage() { const { t } = useTranslation(); const confirm = useConfirm(); 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 categoryLabels: Record = { resource: t('approval.category.resource'), app: t('approval.category.app'), permission: t('approval.category.permission'), }; const stepTypeLabels: Record = { submit: t('approval.step.submit'), audit: t('approval.step.audit'), execute: t('approval.step.execute'), }; const fetchWorkflows = useCallback(async () => { try { setLoading(true); setError(null); const res: JsonData | JsonData[] = await approvalWorkflowApi.list(); const data = Array.isArray(res) ? res : (Array.isArray(res.list) ? res.list : []); const normalized = data.map((w) => ({ ...w, steps: w.steps || [] })); setItems(normalized); } catch { setError(t('approval.msg.fetchFailed')); } finally { setLoading(false); } }, [t]); const fetchUsers = useCallback(async () => { try { const res: JsonData | JsonData[] = await userApi.getUsers(); const data = Array.isArray(res) ? res : (Array.isArray(res.list) ? res.list : []); // Normalize: extract role code from roles array const normalized = data.map((u) => { let role = u.role ?? ''; if (!role && Array.isArray(u.roles) && u.roles.length > 0) { const first = u.roles[0]; role = typeof first === 'string' ? first : (first?.code ?? first?.name ?? ''); } return { ...u, role }; }); setUsers(normalized); } catch { /* ignore */ } }, []); useEffect(() => { fetchWorkflows(); fetchUsers(); }, [fetchWorkflows, fetchUsers]); const toggleActive = useCallback(async (wf: WorkflowItem) => { try { await approvalWorkflowApi.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 ? t('approval.msg.disabled') : t('approval.msg.enabled')); } catch { toast.error(t('common.operationFailed')); } }, [t]); const handleDelete = useCallback(async (id: number, name: string) => { const ok = await confirm({ title: t('approval.msg.deleteTitle'), description: t('approval.msg.deleteConfirm', { name }), confirmText: t('common.delete'), variant: 'destructive', }); if (!ok) return; try { await approvalWorkflowApi.delete(id); setItems((prev) => prev.filter((w) => w.id !== id)); toast.success(t('approval.msg.deleted')); } catch { toast.error(t('approval.msg.deleteFailed')); } }, [confirm, t]); const setDefault = useCallback(async (id: number) => { try { await approvalWorkflowApi.setDefault(id); setItems((prev) => prev.map((w) => ({ ...w, is_default: w.id === id }))); toast.success(t('approval.msg.setDefaultSuccess')); } catch { toast.error(t('common.operationFailed')); } }, [t]); const getUserName = useCallback( (uid: number) => { const u = users.find((u) => u.id === uid); return u ? (u.display_name || u.username) : `#${uid}`; }, [users] ); return ( <> } loading={loading} actions={( )} > {error ? ( {t('approval.retry')} )} /> ) : items.length === 0 ? ( } title={t('approval.noWorkflows')} action={( )} /> ) : (
{items.map((wf) => { const isExpanded = expandedId === wf.id; return (

{wf.name}

{wf.is_default && {t('approval.status.default')}} {wf.is_active ? t('approval.status.enabled') : t('approval.status.disabled')} {categoryLabels[wf.category] || wf.category}
{wf.description &&

{wf.description}

}
{wf.steps?.map((s, i) => ( {stepTypeLabels[s.step_type] || s.step_type} {i < (wf.steps?.length || 0) - 1 && ( → )} ))}
{!wf.is_default && ( )}
{isExpanded && (
{wf.steps?.map((s, i) => { const ids = parseApproverIds(s.approver_ids); return (
{s.step_number}
{s.name} {stepTypeLabels[s.step_type] || s.step_type}
{ids.length > 0 && (
{ids.map((uid) => ( {getUserName(uid)} ))}
)} {ids.length === 0 && s.step_type !== 'submit' && ( {t('approval.detail.noApproverLinked')} )}
); })}
)}
); })}
)}
{editorWorkflow !== undefined && ( setEditorWorkflow(undefined)} onSaved={() => { setEditorWorkflow(undefined); fetchWorkflows(); }} /> )} ); }