refactor(frontend): api/components/pages 目录架构统一对齐菜单域 + 日志中心修复
1. 目录架构统一(API = components = 菜单域,一域一目录 + barrel) - log/ 提为独立顶层域(日志中心在菜单中与监控中心平级,不再塞在 monitor/) - task/ 拆分原 automation.ts → cron-job / script / remote-exec - aiops/ 合并 ai.ts + agentCli.ts → assistant + agent-cli - agent/ approval/ audit/ 提升为域目录 - auth 拆分:authApi(认证自服务) + system/user.ts(userApi+ldapApi 管理端) - components/automation → components/task,菜单"自动化运维"→"作业任务" - pages/ 按域分子目录(approval/audit/monitor),删孤儿 AuditPage.tsx - 所有 API 符号名不变,组件经 @/api 聚合引用,运行时行为零变化 2. Loki 查询无返回修复:aiops 容器 DNS 解析外网域名失败 (宿主机 systemd-resolved 转发链路异常),docker-compose 显式配 dns 上游 3. ES 查询框布局:工具栏单行挤 7 控件(ES 多 150px 索引框),拆两行—— 查询输入独占一行(+查询按钮),次级控件换行 flex-wrap Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
da01cbb119
commit
c0ee8e4ddd
74 files changed
+229
-216
No files matched your search
@@ -0,0 +1,483 @@
|
||||
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
|
||||
import { approvalApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export interface FormField {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
applicant: number; applicant_user?: { username?: string; real_name?: string };
|
||||
template?: { name?: string }; current_step?: number; total_steps?: number;
|
||||
steps?: StepItem[];
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved': return <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />已通过</Badge>;
|
||||
case 'rejected': return <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><XCircle className="h-3 w-3 mr-1" />已拒绝</Badge>;
|
||||
default: return <Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30"><Clock className="h-3 w-3 mr-1" />审批中</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
function parseFormData(data: string): Record<string, string> {
|
||||
try { return JSON.parse(data) || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function getApplicant(p: JsonData): 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);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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: JsonData = { page_size: '100' };
|
||||
if (category) params.category = category;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const resp = (await approvalApi.getProcesses(params)) as JsonData;
|
||||
setProcesses(resp?.list ?? resp?.data?.list ?? []);
|
||||
} catch { setProcesses([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [category, statusFilter]);
|
||||
|
||||
const fetchTemplateId = useCallback(async () => {
|
||||
try {
|
||||
const resp = (await approvalApi.getTemplates({ type: templateCode })) as JsonData;
|
||||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||||
if (list.length > 0) setTemplateId(list[0].id);
|
||||
} catch {}
|
||||
}, [templateCode]);
|
||||
|
||||
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
|
||||
useEffect(() => { fetchTemplateId(); }, [fetchTemplateId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!templateId) { toast.error('模板未加载'); return; }
|
||||
for (const f of fields) { if (f.required && !formData[f.key]) { toast.error(`请填写「${f.label}」`); return; } }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const firstVal = formData[fields[0]?.key] || '';
|
||||
const secondVal = fields[1] ? formData[fields[1].key] || '' : '';
|
||||
await approvalApi.createProcess({
|
||||
template_id: templateId,
|
||||
title: `${title} - ${firstVal}${secondVal ? ' / ' + secondVal : ''}`,
|
||||
form_data: JSON.stringify(formData),
|
||||
});
|
||||
toast.success('申请已提交');
|
||||
setDialogOpen(false); setFormData({}); fetchProcesses();
|
||||
} catch (err) { 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);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: number) => {
|
||||
setActing(true);
|
||||
try { await approvalApi.approveProcess(id); toast.success('已通过'); fetchProcesses(); }
|
||||
catch (err) { 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) { toast.error(err?.response?.data?.message || '操作失败'); }
|
||||
finally { setActing(false); }
|
||||
};
|
||||
|
||||
const openDetail = async (p: ProcessItem) => {
|
||||
// Load full detail including steps
|
||||
try {
|
||||
const resp: JsonData = await approvalApi.getProcesses({ page_size: 200 } as JsonData);
|
||||
const list = resp?.list ?? resp?.data?.list ?? [];
|
||||
const full = list.find((x: JsonData) => 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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{title}</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>
|
||||
</div>
|
||||
<Button onClick={openCreateDialog} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />新建申请
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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)]" />
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-32 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">审批中</SelectItem>
|
||||
<SelectItem value="approved">已通过</SelectItem>
|
||||
<SelectItem value="rejected">已拒绝</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" className="border-[var(--dg-border)]" onClick={fetchProcesses}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : 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" />
|
||||
<p className="text-sm">暂无申请记录</p>
|
||||
<Button variant="link" className="mt-2" onClick={openCreateDialog}>立即申请</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<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-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>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<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)]">新建{title}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
{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>
|
||||
) : 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)]" />
|
||||
) : (
|
||||
<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 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) { 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) { 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user