From 6ba424a8bc4a459eaa04647d6c8b7f33912dcf51 Mon Sep 17 00:00:00 2001 From: attacker Date: Tue, 7 Jul 2026 05:28:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(approval):=20Q10=20ApprovalWorkflowPage=20?= =?UTF-8?q?=E5=B7=A8=E5=9E=8B=E7=BB=84=E4=BB=B6=E6=8B=86=E5=88=86=20(705L?= =?UTF-8?q?=E2=86=92167L,=20-76%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提取 4 个独立文件到 approval-workflow/ 子目录: - types.ts (93L): 接口+常量+工具函数 - UserSelectInline.tsx (96L): 用户多选下拉组件 - WorkflowEditorModal.tsx (272L): 工作流编辑器弹窗 - WorkflowCard.tsx (134L): 工作流卡片组件 验证: tsc -b=0, npm build✓ (16.99s), ESLint errors=0, 17/17 测试通过 --- PROJECT_EVALUATION.md | 2 +- ROADMAP.md | 33 + .../pages/approval/ApprovalWorkflowPage.tsx | 580 +----------------- .../approval-workflow/UserSelectInline.tsx | 96 +++ .../approval-workflow/WorkflowCard.tsx | 134 ++++ .../approval-workflow/WorkflowEditorModal.tsx | 272 ++++++++ .../pages/approval/approval-workflow/types.ts | 93 +++ 7 files changed, 650 insertions(+), 560 deletions(-) create mode 100644 frontend/src/pages/approval/approval-workflow/UserSelectInline.tsx create mode 100644 frontend/src/pages/approval/approval-workflow/WorkflowCard.tsx create mode 100644 frontend/src/pages/approval/approval-workflow/WorkflowEditorModal.tsx create mode 100644 frontend/src/pages/approval/approval-workflow/types.ts diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 3ba434b..b4d96b5 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%),累计提取 16 个独立组件文件** | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6+Q7+Q8+Q9+Q10 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%),累计提取 20 个独立组件文件** | ### 3.2 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index 922790b..17af03f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -481,6 +481,39 @@ services/api.ts (1324L) | Q7 | 前端巨型组件拆分(AgentCLI 814L→521L,提取工具栏+常量+空状态+滚动按钮) | P0 | **done** | | Q8 | 前端巨型组件拆分(SystemSettingsTab 754L→173L,提取通用/安全/LDAP 3 个子 Tab) | P0 | **done** | | Q9 | 前端巨型组件拆分(NamespaceResourcePage 719L→300L,提取 RESOURCE_CONFIG + PodDetailDialog) | P0 | **done** | +| Q10 | 前端巨型组件拆分(ApprovalWorkflowPage 705L→167L,提取 types + UserSelectInline + WorkflowEditorModal + WorkflowCard) | P0 | **done** | + +--- + +## Q10: 前端巨型组件拆分 — ApprovalWorkflowPage [done] + +**问题**: `ApprovalWorkflowPage.tsx`(705 行)是 Q9 拆分后项目最大的业务页面组件之一,包含审批流程配置页面的全部逻辑:类型定义 + 常量 + 工具函数、用户内联多选下拉组件、工作流编辑器弹窗(步骤管理 + 用户分配 + 表单验证 + API 提交)、以及主页面列表渲染(卡片 + 展开详情 + CRUD 操作)。单文件过大导致维护困难、代码导航低效。 + +**已完成 (2026-07-07)**: + +将类型/常量/工具、用户选择组件、编辑器弹窗、卡片组件提取为 `approval-workflow/` 子目录下的独立文件: + +| 新文件 | 行数 | 职责 | +|--------|------|------| +| `types.ts` | 93 | WorkflowStep/WorkflowItem/UserItem/StepDraft 接口 + STEP_TYPE_COLORS/ICONS 常量 + parseApproverIds/resolveAdminId/buildDefaultSteps 工具函数 + EXECUTE_ELIGIBLE_ROLES 角色白名单 | +| `UserSelectInline.tsx` | 96 | 用户内联多选下拉组件(fixed 定位 + 全选/清空 + 标签展示 + 角色徽章) | +| `WorkflowEditorModal.tsx` | 272 | 工作流编辑器弹窗(名称/分类/描述表单 + 步骤增删移改 + 审批人分配 + 表单验证 + API 提交) | +| `WorkflowCard.tsx` | 134 | 单个工作流卡片(标题/徽章/步骤链/操作按钮/展开详情步骤列表) | + +主文件 `ApprovalWorkflowPage.tsx` 从 **705 行降至 167 行**(-76%),仅保留:状态管理(items/users/loading/error/expandedId/editorWorkflow)、数据加载(fetchWorkflows/fetchUsers)、CRUD 操作(toggleActive/handleDelete/setDefault/getUserName)、PageShell 布局 + WorkflowCard 列表渲染、WorkflowEditorModal 集成。 + +**提取原则**: types 文件为纯数据/类型(零 React 依赖,仅 lucide 图标类型引用);UserSelectInline 为自包含受控组件(接收 users/selectedIds/回调 props);WorkflowEditorModal 为完整的编辑器弹窗(内部管理 steps 状态,导入 types + UserSelectInline);WorkflowCard 为纯展示组件(接收 wf + 回调 props,内部用 useTranslation 获取标签)。零逻辑变更。 + +**验收标准**: +- [x] ApprovalWorkflowPage.tsx 从 705 行降至 **167 行**(-76%) +- [x] 4 个提取的文件全部 < 275 行(最大 WorkflowEditorModal 272 行) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 16.99s) +- [x] ESLint errors 保持 **0**(approval-workflow/ 目录零 warnings) +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy +- [x] 审批工作流配置页功能不变(列表/新建/编辑/删除/启用停用/设默认/展开详情全部正常) --- diff --git a/frontend/src/pages/approval/ApprovalWorkflowPage.tsx b/frontend/src/pages/approval/ApprovalWorkflowPage.tsx index 2c2a39d..1163bce 100644 --- a/frontend/src/pages/approval/ApprovalWorkflowPage.tsx +++ b/frontend/src/pages/approval/ApprovalWorkflowPage.tsx @@ -1,461 +1,19 @@ // 审批流程配置 — 参考 OpsxSQL WorkflowPage 工作流模式 -import { useState, useEffect, useCallback, useRef } from 'react'; +// Q10 组件拆分: 类型/常量/工具 → types.ts, 用户选择 → UserSelectInline.tsx, +// 编辑器弹窗 → WorkflowEditorModal.tsx, 卡片 → WorkflowCard.tsx +import { useState, useEffect, useCallback } 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 { Plus, GitBranch } from 'lucide-react'; 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 */ -/* ------------------------------------------------------------------ */ +import { WorkflowEditorModal } from './approval-workflow/WorkflowEditorModal'; +import { WorkflowCard } from './approval-workflow/WorkflowCard'; +import type { WorkflowItem, UserItem } from './approval-workflow/types'; export default function ApprovalWorkflowPage() { const { t } = useTranslation(); @@ -467,18 +25,6 @@ export default function ApprovalWorkflowPage() { 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); @@ -581,7 +127,7 @@ export default function ApprovalWorkflowPage() { /> ) : items.length === 0 ? ( } + icon={} title={t('approval.noWorkflows')} action={( - {!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')} - )} -
-
- ); - })} -
- )} - - - ); - })} + {items.map((wf) => ( + setExpandedId(expandedId === wf.id ? null : wf.id)} + onSetDefault={() => setDefault(wf.id)} + onToggleActive={() => toggleActive(wf)} + onEdit={() => setEditorWorkflow(wf)} + onDelete={() => handleDelete(wf.id, wf.name)} + /> + ))} )} diff --git a/frontend/src/pages/approval/approval-workflow/UserSelectInline.tsx b/frontend/src/pages/approval/approval-workflow/UserSelectInline.tsx new file mode 100644 index 0000000..c8e25ce --- /dev/null +++ b/frontend/src/pages/approval/approval-workflow/UserSelectInline.tsx @@ -0,0 +1,96 @@ +// 审批人内联多选下拉组件 — 从 ApprovalWorkflowPage.tsx 提取 (Q10 组件拆分) +import { useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, X, CheckCircle } from 'lucide-react'; +import type { UserItem } from './types'; + +export 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} + +
+ ); + })} +
+ + )} +
+ ); +} diff --git a/frontend/src/pages/approval/approval-workflow/WorkflowCard.tsx b/frontend/src/pages/approval/approval-workflow/WorkflowCard.tsx new file mode 100644 index 0000000..d4ad9d0 --- /dev/null +++ b/frontend/src/pages/approval/approval-workflow/WorkflowCard.tsx @@ -0,0 +1,134 @@ +// 工作流卡片组件 — 从 ApprovalWorkflowPage.tsx 提取 (Q10 组件拆分) +// 渲染单个审批流程卡片:标题/徽章/步骤链/操作按钮/展开详情 +import { useTranslation } from 'react-i18next'; +import { + Star, Play, Edit2, Trash2, ChevronUp, ChevronDown, +} from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { STEP_TYPE_COLORS, parseApproverIds } from './types'; +import type { WorkflowItem } from './types'; + +export interface WorkflowCardProps { + wf: WorkflowItem; + isExpanded: boolean; + getUserName: (uid: number) => string; + onToggleExpand: () => void; + onSetDefault: () => void; + onToggleActive: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function WorkflowCard({ + wf, isExpanded, getUserName, + onToggleExpand, onSetDefault, onToggleActive, onEdit, onDelete, +}: WorkflowCardProps) { + const { t } = useTranslation(); + + 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'), + }; + + 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')} + )} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/approval/approval-workflow/WorkflowEditorModal.tsx b/frontend/src/pages/approval/approval-workflow/WorkflowEditorModal.tsx new file mode 100644 index 0000000..7b59f72 --- /dev/null +++ b/frontend/src/pages/approval/approval-workflow/WorkflowEditorModal.tsx @@ -0,0 +1,272 @@ +// 工作流编辑器弹窗 — 从 ApprovalWorkflowPage.tsx 提取 (Q10 组件拆分) +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Plus, Trash2, ArrowUp, ArrowDown, Loader2, Shield } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { approvalWorkflowApi } from '@/api'; +import { UserSelectInline } from './UserSelectInline'; +import { + STEP_TYPE_COLORS, STEP_TYPE_ICONS, EXECUTE_ELIGIBLE_ROLES, + parseApproverIds, buildDefaultSteps, +} from './types'; +import type { WorkflowItem, UserItem, StepDraft } from './types'; + +export interface EditorProps { + workflow?: WorkflowItem | null; + users: UserItem[]; + onClose: () => void; + onSaved: () => void; +} + +export 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 => EXECUTE_ELIGIBLE_ROLES.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')} +
+ )} +
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/src/pages/approval/approval-workflow/types.ts b/frontend/src/pages/approval/approval-workflow/types.ts new file mode 100644 index 0000000..48bfbe7 --- /dev/null +++ b/frontend/src/pages/approval/approval-workflow/types.ts @@ -0,0 +1,93 @@ +// 审批流程配置 — 共享类型 + 常量 + 工具函数 +// 从 ApprovalWorkflowPage.tsx 提取 (Q10 组件拆分) +import type React from 'react'; +import { Users, Shield, Play } from 'lucide-react'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export 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; +} + +export interface WorkflowItem { + id: number; + name: string; + description: string | null; + category: string; + is_default: boolean; + is_active: boolean; + created_at: string; + steps: WorkflowStep[]; +} + +export interface UserItem { + id: number; + username: string; + display_name: string | null; + role: string; + is_active?: boolean; +} + +export interface StepDraft { + name: string; + step_type: 'submit' | 'audit' | 'execute'; + approver_ids: number[]; +} + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +export 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', +}; + +export const STEP_TYPE_ICONS: Record = { + submit: Users, + audit: Shield, + execute: Play, +}; + +/** 执行步骤可分配的角色编码白名单 */ +export const EXECUTE_ELIGIBLE_ROLES = ['super_admin', 'k8s_admin', 'release_admin', 'resource_admin']; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** 解析审批人 ID 列表 // Logan.Li */ +export 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 */ +export function resolveAdminId(users: UserItem[]): number[] { + const admin = users.find((u) => u.username === 'admin'); + return admin ? [admin.id] : []; +} + +/** 新建流程默认步骤:提交 → admin 审批 → admin 执行 // Logan.Li */ +export 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 }, + ]; +}