Files
aiops/frontend/src/pages/approval/ApprovalCategoryPage.tsx
T
LoganandClaude 76ec9387f0 feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物
主要变更:
- RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、
  backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API
- Windows SMB 文件传输:backend/pkg/winsmb
- 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情
- Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs
- 服务器导入助手:server_import_helpers、ImportCredentialSettings
- 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块)
- 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-*
- .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 01:50:36 +08:00

496 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 审批分类通用页面 — 资源申请/应用申请/权限审批三个子页面共用
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
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, Eye, Check, XIcon, User, ChevronRight, Play } from 'lucide-react';
import { approvalApi } from '@/api';
import { toast } from 'sonner';
import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
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, t: (k: string) => string) => {
switch (status) {
case 'approved': return <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><CheckCircle className="h-3 w-3 mr-1" />{t('approval.status.approved')}</Badge>;
case 'rejected': return <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><XCircle className="h-3 w-3 mr-1" />{t('approval.status.rejected')}</Badge>;
default: return <Badge className="bg-amber-500/20 dg-text-yellow border-amber-500/30"><Clock className="h-3 w-3 mr-1" />{t('approval.status.pending')}</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, t: (k: string) => string): string {
if (p.status !== 'pending') return '--';
const steps = p.steps || [];
if (steps.length === 0) return t('approval.detail.adminPending');
const cs = steps.find(s => s.step === p.current_step && s.status === 'pending');
if (!cs) return t('approval.detail.adminPending');
if (cs.approver_user) return cs.approver_user.real_name || cs.approver_user.username || `#${cs.approver}`;
return t('approval.detail.admin');
}
export function ApprovalCategoryPage({ category, title, description, fields, templateCode }: Props) {
const { t } = useTranslation();
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 {
// 模板缺失时保留空状态,由创建流程提示用户。// Logan.Li
}
}, [templateCode]);
useEffect(() => { fetchProcesses(); }, [fetchProcesses]);
useEffect(() => { fetchTemplateId(); }, [fetchTemplateId]);
const handleCreate = async () => {
if (!templateId) { toast.error(t('approval.msg.templateNotLoaded')); return; }
for (const f of fields) { if (f.required && !formData[f.key]) { toast.error(t('approval.msg.pleaseFill', { label: 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(t('approval.msg.submitSuccess'));
setDialogOpen(false); setFormData({}); fetchProcesses();
} catch (err) { toast.error(err?.message || t('approval.msg.submitFailed')); }
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(t('approval.msg.approveSuccess')); fetchProcesses(); }
catch (err) { toast.error(err?.response?.data?.message || t('approval.msg.approveFailed')); }
finally { setActing(false); }
};
const handleReject = async (id: number) => {
setActing(true);
try { await approvalApi.rejectProcess(id, t('approval.flow.rejectTitle')); toast.success(t('approval.msg.rejectSuccess')); fetchProcesses(); }
catch (err) { toast.error(err?.response?.data?.message || t('common.operationFailed')); }
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()));
// 工具栏:搜索 + 状态筛选 // Logan.Li
const toolbar = (
<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 dg-text-muted" />
<Input placeholder={t('approval.flow.searchRequest')} value={keyword} onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9 dg-input" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 dg-input">
<SelectValue placeholder={t('common.all')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('common.all')}</SelectItem>
<SelectItem value="pending">{t('approval.status.pending')}</SelectItem>
<SelectItem value="approved">{t('approval.status.approved')}</SelectItem>
<SelectItem value="rejected">{t('approval.status.rejected')}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchProcesses}>
<RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}
</Button>
</div>
);
return (
<>
<PageShell
variant="list"
title={title}
subtitle={description}
icon={<FileText className="h-5 w-5" />}
toolbar={toolbar}
loading={loading}
actions={(
<Button onClick={openCreateDialog} className="dg-btn-primary">
<Plus className="h-4 w-4 mr-2" />{t('approval.newRequest')}
</Button>
)}
>
<ContentPanel noPadding>
{filtered.length === 0 ? (
<PageEmpty
icon={<FileText className="h-10 w-10" />}
title={t('approval.noRecords')}
action={(
<Button variant="link" onClick={openCreateDialog}>{t('approval.applyNow')}</Button>
)}
/>
) : (
<Table>
<TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.submitTime')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.submitAccount')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.currentApprover')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)]">{t('approval.col.status')}</TableHead>
<TableHead className="text-[var(--dg-text-secondary)] w-28">{t('approval.col.actions')}</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, t)}</TableCell>
<TableCell>{statusBadge(p.status, t)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{p.status === 'pending' ? (
<>
<Button variant="ghost" size="sm" className="h-8 dg-text-red hover:bg-red-500/10" onClick={() => handleReject(p.id)} disabled={acting}>
<XIcon className="h-3.5 w-3.5 mr-1" />{t('common.reject')}
</Button>
<Button variant="ghost" size="sm" className="h-8 dg-text-green hover:bg-emerald-500/10" onClick={() => handleApprove(p.id)} disabled={acting}>
<Check className="h-3.5 w-3.5 mr-1" />{t('common.approve')}
</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" />{t('common.detail')}
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</ContentPanel>
</PageShell>
{/* 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)]">{t('approval.newRequest')} {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="dg-text-red 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 || `${t('approval.editor.pleaseSelect')}${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)}>{t('common.cancel')}</Button>
<Button onClick={handleCreate} disabled={submitting} className="dg-btn-primary">
{submitting ? t('approval.submitting') : t('approval.submitRequest')}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Detail Dialog — operation timeline */}
<DetailTimelineDialog item={detailItem} onClose={() => setDetailItem(null)} fields={fields} onStatusChange={fetchProcesses} />
</>
);
}
/* ------------------------------------------------------------------ */
/* DetailTimelineDialog — 操作时间线 */
/* ------------------------------------------------------------------ */
function DetailTimelineDialog({ item, onClose, fields, onStatusChange }: {
item: ProcessItem | null; onClose: () => void; fields: FormField[]; onStatusChange: () => void;
}) {
const { t } = useTranslation();
const [acting, setActing] = useState(false);
if (!item) return null;
const timeStr = (tm: string | null) => {
if (!tm) return '';
return new Date(tm).toLocaleString('zh-CN');
};
const steps = item.steps || [];
const handleApprove = async () => {
setActing(true);
try { await approvalApi.approveProcess(item.id); toast.success(t('approval.msg.approveSuccess')); onStatusChange(); onClose(); }
catch (err) { toast.error(err?.response?.data?.message || t('approval.msg.approveFailed')); }
finally { setActing(false); }
};
const handleReject = async () => {
setActing(true);
try { await approvalApi.rejectProcess(item.id, t('approval.flow.rejectTitle')); toast.success(t('approval.msg.rejectSuccess')); onStatusChange(); onClose(); }
catch (err) { toast.error(err?.response?.data?.message || t('common.operationFailed')); }
finally { setActing(false); }
};
const stepStatusLabel = (s: StepItem) => {
if (s.status === 'approved') return t('approval.status.passed');
if (s.status === 'rejected') return t('common.reject');
if (s.status === 'pending' && s.step === item.current_step) return t('approval.status.waiting');
if (s.status === 'pending') return t('approval.status.notStarted');
return s.status;
};
const stepStatusColor = (s: StepItem) => {
if (s.status === 'approved') return 'dg-text-green';
if (s.status === 'rejected') return 'dg-text-red';
if (s.step === item.current_step) return 'dg-text-blue';
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 t('approval.detail.admin');
};
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)]">{t('approval.detail.title')}</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)]">{t('approval.detail.statusLabel')}</span>
{statusBadge(item.status, t)}
{item.status === 'pending' && (
<span className="text-xs text-[var(--dg-text-muted)]">
{t('approval.detail.stepProgress', { current: item.current_step, total: 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">{t('approval.detail.operationTimeline')}</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 dg-text-blue" /></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)]">{t('approval.detail.submitNode')}</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 ? t('approval.step.executeShort') : t('approval.step.auditShort');
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 dg-text-green" />
: s.status === 'rejected' ? <XIcon className="w-3.5 h-3.5 dg-text-red" />
: isExecute
? <Play className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-green' : 'text-[var(--dg-text-muted)]'}`} />
: <ChevronRight className={`w-3.5 h-3.5 ${s.step === item.current_step ? 'dg-text-blue' : '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)]">{t('approval.detail.assignee')}: {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">{t('approval.detail.requestContent')}</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 dg-text-red hover:bg-red-500/10" onClick={handleReject} disabled={acting}>
<XIcon className="h-4 w-4 mr-1.5" />{t('common.reject')}
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleApprove} disabled={acting}>
<Check className="h-4 w-4 mr-1.5" />{t('common.approve')}
</Button>
</>
)}
<Button variant="outline" className="border-[var(--dg-border)]" onClick={onClose}>{t('common.close')}</Button>
</div>
</DialogContent>
</Dialog>
);
}