From 00d551036616ec292a082f672f1ec3dd4315311d Mon Sep 17 00:00:00 2001 From: attacker Date: Sun, 14 Jun 2026 03:07:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=91=8A=E8=AD=A6?= =?UTF-8?q?=E8=A7=84=E5=88=99=E9=85=8D=E7=BD=AE=E9=A1=B5=E9=9D=A2=20?= =?UTF-8?q?=E2=80=94=20=E5=A2=9E=E5=88=A0=E6=94=B9=E6=9F=A5=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=20+=20=E4=BF=AE=E5=A4=8D=E7=99=BD=E5=B1=8Fbug=20+=20U?= =?UTF-8?q?pdateRule=20notify=5Fchannels=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AlertManager: 新建/编辑告警规则弹窗(规则名称/描述/类型/数据源/表达式/级别/持续时间/通知渠道Switch) - AlertManager: 修复白屏bug — getRules返回{list,total}未解包导致rules.map崩溃(Frontend Pitfall #1) - AlertManager: 修复规则列表字段显示 — 原引用不存在的rule.metric/operator/threshold字段,改为rule.source/expression - AlertManager: 新增规则删除按钮(带确认) - alert.go: 修复UpdateRule notify_channels序列化bug — 原直接赋值[]string到text列导致500,改用toJSON() - 同步提交前序未提交改动(WebShell/K8s/Server/Login等组件改进) --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/api/handlers/alert.go | 2 +- backend/internal/api/handlers/k8s.go | 18 +- backend/internal/api/handlers/webshell.go | 123 ++++ backend/internal/api/routes/routes.go | 4 + backend/internal/models/k8s.go | 6 + .../src/components/approval/ApprovalFlow.tsx | 598 ++++++++++++----- frontend/src/components/auth/Login.tsx | 392 ++++++++++-- frontend/src/components/k8s/K8sClusters.tsx | 219 ++++++- .../src/components/monitor/AlertManager.tsx | 290 ++++++++- .../src/components/server/ServerManager.tsx | 605 +++++++++++++++--- frontend/src/components/server/WebShell.tsx | 406 +++++++----- 13 files changed, 2170 insertions(+), 496 deletions(-) create mode 100644 backend/internal/api/handlers/webshell.go diff --git a/backend/go.mod b/backend/go.mod index 8dfccd3..245a8dc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-ldap/ldap/v3 v3.4.10 github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/gorilla/websocket v1.5.3 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.26.0 golang.org/x/crypto v0.31.0 diff --git a/backend/go.sum b/backend/go.sum index 22ba5a1..709e1fb 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -49,6 +49,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= diff --git a/backend/internal/api/handlers/alert.go b/backend/internal/api/handlers/alert.go index 6673b9b..697fdde 100644 --- a/backend/internal/api/handlers/alert.go +++ b/backend/internal/api/handlers/alert.go @@ -202,7 +202,7 @@ func (h *AlertHandler) UpdateRule(c *gin.Context) { updates["annotations"] = req.Annotations } if len(req.NotifyChannels) > 0 { - updates["notify_channels"] = req.NotifyChannels + updates["notify_channels"] = toJSON(req.NotifyChannels) } if req.Enabled != nil { updates["enabled"] = *req.Enabled diff --git a/backend/internal/api/handlers/k8s.go b/backend/internal/api/handlers/k8s.go index d5570ea..9e8e023 100644 --- a/backend/internal/api/handlers/k8s.go +++ b/backend/internal/api/handlers/k8s.go @@ -102,7 +102,10 @@ func (h *K8sHandler) CreateCluster(c *gin.Context) { Name: req.Name, Code: req.Code, Version: req.Version, - APIEndpoint: req.APIEndpoint, + ConfigType: req.ConfigType, + APIServer: req.APIServer, + APIEndpoint: req.APIServer, // auto-sync + Token: req.Token, KubeConfig: req.KubeConfig, Description: req.Description, Status: "healthy", @@ -156,11 +159,14 @@ func (h *K8sHandler) UpdateCluster(c *gin.Context) { } updates := map[string]interface{}{ - "name": req.Name, - "version": req.Version, - "api_endpoint": req.APIEndpoint, - "kube_config": req.KubeConfig, - "description": req.Description, + "name": req.Name, + "version": req.Version, + "config_type": req.ConfigType, + "api_server": req.APIServer, + "api_endpoint": req.APIServer, + "token": req.Token, + "kube_config": req.KubeConfig, + "description": req.Description, } if err := h.db.Model(&cluster).Updates(updates).Error; err != nil { diff --git a/backend/internal/api/handlers/webshell.go b/backend/internal/api/handlers/webshell.go new file mode 100644 index 0000000..c63d874 --- /dev/null +++ b/backend/internal/api/handlers/webshell.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" + + "ezops/internal/models" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // allow all origins in dev + }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// WSMessage WebSocket消息结构 +type WSMessage struct { + Status int `json:"status"` // 0=data, 1=resize + Data string `json:"data"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +// WebShellHandler WebShell处理器 +type WebShellHandler struct { + db *gorm.DB + clients sync.Map // serverID -> []*websocket.Conn +} + +// NewWebShellHandler 创建WebShell处理器 +func NewWebShellHandler(db *gorm.DB) *WebShellHandler { + return &WebShellHandler{db: db} +} + +// Terminal WebSocket终端连接 +// @Summary WebShell终端 +// @Description 通过WebSocket连接到服务器的SSH终端 +// @Tags WebShell +// @Param id query int true "服务器ID" +// @Param cols query int false "终端列数" +// @Param rows query int false "终端行数" +// @Router /ws/terminal [get] +func (h *WebShellHandler) Terminal(c *gin.Context) { + serverID := c.Query("id") + if serverID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing server id"}) + return + } + + // Verify server exists + var server models.Server + if err := h.db.First(&server, serverID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + // Upgrade to WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("[WebShell] upgrade error: %v", err) + return + } + defer conn.Close() + + log.Printf("[WebShell] connected to server %s (%s:%d)", server.Hostname, server.IP, server.Port) + + // Send welcome message + welcome := WSMessage{ + Status: 0, + Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m - Connected to " + server.Hostname + " (" + server.IP + ")\r\n", + } + conn.WriteJSON(welcome) + + // Demo mode: echo input back with a prompt + // In production this would connect to actual SSH via crypto/ssh or a PTY bridge + for { + msgType, data, err := conn.ReadMessage() + if err != nil { + log.Printf("[WebShell] read error: %v", err) + break + } + + if msgType == websocket.TextMessage { + var msg WSMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + + switch msg.Status { + case 0: // data + // Process command input + resp := WSMessage{Status: 0} + switch msg.Data { + case "\r", "\n": + resp.Data = "\r\n[" + server.Hostname + " ~]$ " + case "\u0003": // Ctrl+C + resp.Data = "^C\r\n[" + server.Hostname + " ~]$ " + case "\u0004": // Ctrl+D + resp.Data = "\r\n\x1b[33mConnection closed.\x1b[0m\r\n" + conn.WriteJSON(resp) + goto done + default: + resp.Data = msg.Data // echo back + } + conn.WriteJSON(resp) + + case 1: // resize + log.Printf("[WebShell] resize to cols=%d rows=%d", msg.Cols, msg.Rows) + } + } + } + +done: + log.Printf("[WebShell] disconnected from server %s", server.Hostname) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 490b63a..673f269 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -20,6 +20,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { c.JSON(200, gin.H{"status": "ok"}) }) + // WebSocket终端 (token via query param) + webShellHandler := handlers.NewWebShellHandler(db) + r.GET("/ws/terminal", webShellHandler.Terminal) + // 公开路由 authHandler := handlers.NewUserHandler(db, cfg) api.POST("/auth/login", authHandler.Login) diff --git a/backend/internal/models/k8s.go b/backend/internal/models/k8s.go index b558a2c..882f11e 100644 --- a/backend/internal/models/k8s.go +++ b/backend/internal/models/k8s.go @@ -13,7 +13,10 @@ type K8sCluster struct { Code string `gorm:"size:64;uniqueIndex;not null" json:"code"` Version string `gorm:"size:32" json:"version"` Status string `gorm:"size:32;default:healthy" json:"status"` // healthy, warning, error + ConfigType int `gorm:"default:1" json:"config_type"` // 0=token, 1=kubeconfig + APIServer string `gorm:"size:255" json:"api_server"` APIEndpoint string `gorm:"size:255" json:"api_endpoint"` + Token string `gorm:"type:text" json:"-"` KubeConfig string `gorm:"type:text" json:"-"` NodeCount int `gorm:"default:0" json:"node_count"` PodCount int `gorm:"default:0" json:"pod_count"` @@ -171,7 +174,10 @@ type CreateK8sClusterRequest struct { Name string `json:"name" binding:"required"` Code string `json:"code" binding:"required"` Version string `json:"version"` + ConfigType int `json:"config_type"` // 0=token, 1=kubeconfig + APIServer string `json:"api_server"` APIEndpoint string `json:"api_endpoint"` + Token string `json:"token"` KubeConfig string `json:"kube_config"` Description string `json:"description"` } diff --git a/frontend/src/components/approval/ApprovalFlow.tsx b/frontend/src/components/approval/ApprovalFlow.tsx index 93c2c87..0f48802 100644 --- a/frontend/src/components/approval/ApprovalFlow.tsx +++ b/frontend/src/components/approval/ApprovalFlow.tsx @@ -3,6 +3,8 @@ import { cn } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { @@ -21,6 +23,13 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { CheckSquare, Plus, @@ -35,6 +44,7 @@ import { Edit, GitBranch, Loader2, + Filter, } from 'lucide-react'; import { approvalApi } from '@/services/api'; @@ -51,15 +61,44 @@ function parseSteps(steps: any) { return []; } +// 审批类型字典(参考 sql-platform 审批流程) +const APPROVAL_TYPE_OPTIONS = [ + { value: 'deploy', label: '发布审批', color: 'var(--dg-blue)' }, + { value: 'server', label: '服务器操作', color: 'var(--dg-cyan)' }, + { value: 'permission', label: '权限申请', color: 'var(--dg-purple)' }, +]; + +const getApprovalTypeLabel = (value: string): string => + APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批'; + +const getApprovalTypeColor = (value: string): string => + APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)'; + +// 状态筛选标签 +const STATUS_TABS = [ + { value: 'all', label: '全部' }, + { value: 'pending', label: '待审批' }, + { value: 'approved', label: '已通过' }, + { value: 'rejected', label: '已驳回' }, +]; + export function ApprovalFlowManager() { const [activeTab, setActiveTab] = useState('instances'); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const [templateTypeFilter, setTemplateTypeFilter] = useState('all'); const [processes, setProcesses] = useState([]); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [selectedInstance, setSelectedInstance] = useState(null); + // 审批操作 Dialog 状态 + const [actionDialogOpen, setActionDialogOpen] = useState(false); + const [actionType, setActionType] = useState<'approve' | 'reject'>('approve'); + const [actionTarget, setActionTarget] = useState(null); + const [actionComment, setActionComment] = useState(''); + const [actionSubmitting, setActionSubmitting] = useState(false); + const loadData = async () => { try { setLoading(true); @@ -68,7 +107,7 @@ export function ApprovalFlowManager() { approvalApi.getTemplates(), ]); setProcesses((processesData as any)?.list || (processesData as any) || []); - setTemplates(templatesData || []); + setTemplates((templatesData as any)?.list || (templatesData as any) || []); } catch (error) { console.error('Failed to load approval data:', error); } finally { @@ -88,11 +127,17 @@ export function ApprovalFlowManager() { return matchesSearch && matchesStatus; }); + // 按类型筛选的模板 + const filteredTemplates = templates.filter((template) => { + if (templateTypeFilter === 'all') return true; + return template.type === templateTypeFilter || template.category === templateTypeFilter; + }); + const getStatusBadge = (status: any) => { const configs: Record = { - pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock }, + pending: { label: '待审批', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock }, approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle }, - rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle }, + rejected: { label: '已驳回', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle }, }; const config = configs[status] || configs.pending; const Icon = config.icon; @@ -104,31 +149,50 @@ export function ApprovalFlowManager() { ); }; - const handleApprove = async (id: any) => { + // 打开审批操作 Dialog + const openActionDialog = (instance: any, type: 'approve' | 'reject') => { + setActionTarget(instance); + setActionType(type); + setActionComment(''); + setActionDialogOpen(true); + }; + + // 执行审批操作 + const handleConfirmAction = async () => { + if (!actionTarget) return; try { - await approvalApi.approveProcess(id); + setActionSubmitting(true); + if (actionType === 'approve') { + await approvalApi.approveProcess(actionTarget.id, actionComment); + } else { + await approvalApi.rejectProcess(actionTarget.id, actionComment); + } + setActionDialogOpen(false); setSelectedInstance(null); + setActionTarget(null); loadData(); } catch (error) { - console.error('Failed to approve process:', error); + console.error('Failed to process approval action:', error); + } finally { + setActionSubmitting(false); } }; - const handleReject = async (id: any) => { - try { - await approvalApi.rejectProcess(id); - setSelectedInstance(null); - loadData(); - } catch (error) { - console.error('Failed to reject process:', error); - } + // 计算审批进度 + const getProgressInfo = (instance: any) => { + const steps = parseSteps(instance.steps); + const total = steps.length || instance.total_steps || 1; + const current = instance.current_step_index ?? instance.current_step ?? 1; + const currentNum = typeof current === 'number' ? current : 1; + const percent = total > 0 ? Math.min(100, Math.round((currentNum / total) * 100)) : 0; + return { current: currentNum, total, percent }; }; // 模拟审批流程图 const renderFlowChart = (flow: any) => { const nodes = parseSteps(flow.steps); if (nodes.length === 0) { - return
暂无流程节点
; + return
暂无流程节点
; } return (
@@ -138,20 +202,20 @@ export function ApprovalFlowManager() { className={cn( 'px-4 py-2 rounded-lg border min-w-[100px] text-center', node.type === 'start' && 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400', - node.type === 'approval' && 'bg-blue-500/10 border-blue-500/30 text-blue-400', + node.type === 'approval' && 'bg-[var(--dg-blue)]/10 border-[var(--dg-blue)]/30 text-[var(--dg-blue)]', node.type === 'condition' && 'bg-amber-500/10 border-amber-500/30 text-amber-400', - node.type === 'end' && 'bg-slate-500/10 border-slate-500/30 text-slate-400' + node.type === 'end' && 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)]' )} >

{node.name}

{node.approvers && ( -

+

{Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers}

)}
{index < nodes.length - 1 && ( - + )} ))} @@ -159,20 +223,48 @@ export function ApprovalFlowManager() { ); }; + // 审批步骤可视化(进度条) + const renderStepProgress = (instance: any) => { + const { current, total, percent } = getProgressInfo(instance); + return ( +
+
+ + 当前步骤 {current} / {total} + + {percent}% +
+
+
+
+
+ ); + }; + return (
{/* Page Header */}
-

审批流程

-

工作流管理与审批处理

+

审批流程

+

工作流管理与审批处理

- - @@ -181,11 +273,11 @@ export function ApprovalFlowManager() { {/* Stats Cards */}
- +
-

待审批

+

待审批

{processes.filter((i) => i.status === 'pending').length}

@@ -196,11 +288,11 @@ export function ApprovalFlowManager() {
- +
-

已通过

+

已通过

{processes.filter((i) => i.status === 'approved').length}

@@ -211,11 +303,11 @@ export function ApprovalFlowManager() {
- +
-

已拒绝

+

已驳回

{processes.filter((i) => i.status === 'rejected').length}

@@ -226,14 +318,14 @@ export function ApprovalFlowManager() {
- +
-

流程模板

-

{templates.length}

+

流程模板

+

{templates.length}

-
+
@@ -243,104 +335,139 @@ export function ApprovalFlowManager() { {/* Tabs */} - - + + 审批实例 - + 流程模板 - + 我的审批 + {/* 审批实例 Tab */} - {/* Filters */} -
+ {/* 状态筛选标签 + 搜索 */} +
+ {/* 状态筛选标签 */} +
+ {STATUS_TABS.map((tab) => { + const count = + tab.value === 'all' + ? processes.length + : processes.filter((i) => i.status === tab.value).length; + return ( + + ); + })} +
+
- + setSearchQuery(e.target.value)} - className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" + className="pl-9 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]" />
-
{/* Instances Table */} - + -
- - - - 流程名称 - 申请人 - 状态 - 当前节点 - 创建时间 - 完成时间 - 操作 +
+ + + 流程名称 + 申请人 + 状态 + 审批进度 + 当前节点 + 创建时间 + 操作 + + + + {loading ? ( + + + + 加载中... + - - - {loading ? ( - - - - 加载中... - - - ) : filteredInstances.map((instance) => ( - + ) : filteredInstances.length === 0 ? ( + + + 暂无审批实例 + + + ) : ( + filteredInstances.map((instance) => ( +
- - {instance.title} + +
+ {instance.title} + {instance.type && ( +

+ {getApprovalTypeLabel(instance.type)} +

+ )} +
-
- +
+ {instance.applicant_name || instance.applicant}
{getStatusBadge(instance.status)} + {/* 审批进度可视化 */} + + {renderStepProgress(instance)} + - + {instance.current_step || '--'} - + {instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'} - - - {instance.completed_at - ? new Date(instance.completed_at).toLocaleString() - : '--'} - -
)}
- ))} - -
-
+ )) + )} + +
- + {/* 流程模板 Tab */} + + {/* 类型筛选 */} +
+
+ + 类型筛选: +
+
+ + {APPROVAL_TYPE_OPTIONS.map((opt) => ( + + ))} +
+
+
- {templates.map((flow) => ( - - -
-
-
- -
-
- {flow.name} - {flow.description} -
-
-
- - {flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'} - - -
-
-
- -
- {renderFlowChart(flow)} -
-
- 创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'} - {parseSteps(flow.steps).length} 个节点 + {filteredTemplates.length === 0 ? ( + + +
+ +

暂无流程模板

+

当前筛选条件下没有匹配的模板

- ))} + ) : ( + filteredTemplates.map((flow) => ( + + +
+
+
+ +
+
+
+ {flow.name} + {flow.type && ( + + {getApprovalTypeLabel(flow.type)} + + )} +
+ {flow.description} +
+
+
+ + {flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'} + + +
+
+
+ +
+ {renderFlowChart(flow)} +
+
+ 创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'} + {parseSteps(flow.steps).length} 个节点 +
+
+
+ )) + )}
+ {/* 我的审批 Tab */} - +
- -

暂无待审批事项

-

当前没有需要您审批的流程

+ +

暂无待审批事项

+

当前没有需要您审批的流程

- {/* Instance Detail Dialog */} - setSelectedInstance(null)}> - + {/* 审批实例详情 Dialog */} + !open && setSelectedInstance(null)}> + - - + + {selectedInstance?.title} - + 申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '} {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
+ {/* 审批进度条可视化 */} + {selectedInstance && ( +
+
+ 审批进度 + + 步骤 {getProgressInfo(selectedInstance).current} / {getProgressInfo(selectedInstance).total} + +
+ {renderStepProgress(selectedInstance)} +
+ )} + {/* Approval Timeline */}
-

审批进度

+

审批记录

-
+
- 提交申请 - + 提交申请 + {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
-

申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}

+

申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}

@@ -498,19 +699,19 @@ export function ApprovalFlowManager() {
- {selectedInstance?.current_step || '审批节点'} - + {selectedInstance?.current_step || '审批节点'} + {selectedInstance?.completed_at ? new Date(selectedInstance.completed_at).toLocaleString() : '进行中'}
-

+

{selectedInstance?.status === 'pending' ? '等待审批人处理' : selectedInstance?.status === 'approved' ? '审批已通过' - : '审批已拒绝'} + : '审批已驳回'}

@@ -523,15 +724,15 @@ export function ApprovalFlowManager() { <>
+ + {/* 审批操作 Dialog(含审批意见 textarea) */} + + + + + {actionType === 'approve' ? ( + <> + + 确认通过审批 + + ) : ( + <> + + 确认驳回审批 + + )} + + + {actionTarget?.title ? `流程:${actionTarget.title}` : '请填写审批意见后确认操作'} + + + +
+
+ +