feat: 资源申请模块重构——清理816行死代码+提取共享helpers+修复类型标签bug

- 删除 CabinetView.tsx(422行) + ResourceModelManager.tsx(394行) 死代码
- 提取 approval/helpers.tsx 共享辅助函数(parseSteps/parseFields/renderFormData/
  getProgressInfo/renderStepProgress/APPROVAL_TYPE_OPTIONS/getInstanceType)
- 修复审批类型标签bug:instance无type字段,需从template.type取
  (getApprovalTypeLabel/Color/Icon 全部改用 getInstanceType(instance))
- ApprovalFlow.tsx 771→666行(低于700限制)
- ApprovalProcessTab.tsx 移除重复helpers+过期类型字典(deploy/server/permission→server_apply/app_deploy)
This commit is contained in:
attacker committed 2026-06-15 08:07:10 +08:00
1 parent 8c4b59510d
commit f70feb360c
5 files changed
+155 -1020

No files matched your search

+15 -120
View File
@@ -22,133 +22,28 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import {
GitBranch,
FileText, FileText,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
Clock, Clock,
Plus, Plus,
X,
GitCommit,
Server,
KeyRound,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { approvalApi } from '@/services/api'; import { approvalApi } from '@/services/api';
import { ApprovalTemplateTab } from './ApprovalTemplateTab'; import { ApprovalTemplateTab } from './ApprovalTemplateTab';
import { ApprovalProcessTab } from './ApprovalProcessTab'; import { ApprovalProcessTab } from './ApprovalProcessTab';
import {
// ---- Shared helpers (kept in main for dialog usage) ---- parseFields,
renderFormData,
function parseSteps(steps: any) { renderStepProgress,
if (Array.isArray(steps)) return steps; getProgressInfo,
if (typeof steps === 'string') { getApprovalTypeLabel,
try { getApprovalTypeColor,
const parsed = JSON.parse(steps); getInstanceType,
return Array.isArray(parsed) ? parsed : []; APPROVAL_TYPE_OPTIONS,
} catch { } from './helpers';
return [];
}
}
return [];
}
// 解析模板的 form_schema,返回字段定义数组
function parseFields(formSchema: any): any[] {
if (!formSchema) return [];
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
if (!schema) return [];
return Array.isArray(schema.fields) ? schema.fields : [];
}
// 在详情弹窗中渲染表单数据为键值对展示
function renderFormData(formDataStr: string, template?: any) {
if (!formDataStr) return null;
let data: Record<string, any>;
try {
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
} catch {
return null;
}
if (!data || typeof data !== 'object') return null;
// 优先使用模板的 form_schema 标签,否则退化为 key
const fields = parseFields(template?.form_schema);
const labelMap = new Map<string, string>();
fields.forEach((f) => labelMap.set(f.key, f.label));
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
if (entries.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{entries.map(([key, value]) => (
<div key={key} className="min-w-0">
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
</div>
))}
</div>
);
}
const APPROVAL_TYPE_OPTIONS = [
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
];
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)';
function getProgressInfo(instance: any) {
const steps = parseSteps(instance.steps);
const currentIndex = instance.current_step ?? 0;
const total = steps.length;
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { steps, currentIndex, total, completed, percentage };
}
// 渲染审批进度(用于详情弹窗)
function renderStepProgress(instance: any) {
const { steps, currentIndex } = getProgressInfo(instance);
if (steps.length === 0) return null;
return (
<div className="flex items-center gap-1 overflow-x-auto">
{steps.map((step: any, index: number) => {
const isCompleted = step.status === 'approved' || step.status === 'completed';
const isCurrent = index === currentIndex;
const isRejected = step.status === 'rejected';
return (
<div key={step.id || index} className="flex items-center">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
isCompleted && 'bg-emerald-500/10 text-emerald-400',
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
isRejected && 'bg-red-500/10 text-red-400',
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
)}
>
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
{isRejected && <XCircle className="h-3 w-3" />}
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
</div>
{index < steps.length - 1 && (
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
)}
</div>
);
})}
</div>
);
}
export function ApprovalFlow() { export function ApprovalFlow() {
const confirm = useConfirm(); const confirm = useConfirm();
@@ -427,14 +322,14 @@ export function ApprovalFlow() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(instance.type) }}> <div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
<FileText className="h-5 w-5" /> <FileText className="h-5 w-5" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-[var(--dg-text-primary)]">{instance.title}</h3> <h3 className="font-medium text-[var(--dg-text-primary)]">{instance.title}</h3>
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(instance.type) }}> <Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
{getApprovalTypeLabel(instance.type)} {getApprovalTypeLabel(getInstanceType(instance))}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-[var(--dg-text-muted)]"> <p className="text-xs text-[var(--dg-text-muted)]">
@@ -494,8 +389,8 @@ export function ApprovalFlow() {
<div> <div>
<h3 className="text-lg font-medium text-[var(--dg-text-primary)]">{selectedInstance.title}</h3> <h3 className="text-lg font-medium text-[var(--dg-text-primary)]">{selectedInstance.title}</h3>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(selectedInstance.type) }}> <Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(selectedInstance)) }}>
{getApprovalTypeLabel(selectedInstance.type)} {getApprovalTypeLabel(getInstanceType(selectedInstance))}
</Badge> </Badge>
<span className="text-xs text-[var(--dg-text-muted)]"> <span className="text-xs text-[var(--dg-text-muted)]">
申请人:{selectedInstance.applicant_name || selectedInstance.applicant || '--'} 申请人:{selectedInstance.applicant_name || selectedInstance.applicant || '--'}
@@ -9,40 +9,16 @@ import {
XCircle, XCircle,
Eye, Eye,
Loader2, Loader2,
GitCommit,
Server,
KeyRound,
FileText, FileText,
} from 'lucide-react'; } from 'lucide-react';
import {
function parseSteps(steps: any) { getProgressInfo,
if (Array.isArray(steps)) return steps; renderStepProgress,
if (typeof steps === 'string') { getApprovalTypeLabel,
try { getApprovalTypeColor,
const parsed = JSON.parse(steps); getApprovalTypeIcon,
return Array.isArray(parsed) ? parsed : []; getInstanceType,
} catch { } from './helpers';
return [];
}
}
return [];
}
// 审批类型字典
const APPROVAL_TYPE_OPTIONS = [
{ value: 'deploy', label: '发布审批', icon: GitCommit, color: 'var(--dg-blue)' },
{ value: 'server', label: '服务器操作', icon: Server, color: 'var(--dg-cyan)' },
{ value: 'permission', label: '权限申请', icon: KeyRound, 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 getApprovalTypeIcon = (value: string): React.ElementType =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
// 状态标签页定义 // 状态标签页定义
const STATUS_TABS = [ const STATUS_TABS = [
@@ -92,54 +68,6 @@ function getStatusBadge(status: string) {
); );
} }
// 获取进度信息
function getProgressInfo(instance: any) {
const steps = parseSteps(instance.steps);
const currentIndex = instance.current_step ?? 0;
const total = steps.length;
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { steps, currentIndex, total, completed, percentage };
}
// 渲染审批进度
function renderStepProgress(instance: any) {
const { steps, currentIndex } = getProgressInfo(instance);
if (steps.length === 0) return null;
return (
<div className="flex items-center gap-1 overflow-x-auto">
{steps.map((step: any, index: number) => {
const isCompleted = step.status === 'approved' || step.status === 'completed';
const isCurrent = index === currentIndex;
const isRejected = step.status === 'rejected';
return (
<div key={step.id || index} className="flex items-center">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
isCompleted && 'bg-emerald-500/10 text-emerald-400',
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
isRejected && 'bg-red-500/10 text-red-400',
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
)}
>
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
{isRejected && <XCircle className="h-3 w-3" />}
{isCurrent && !isRejected && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
</div>
{index < steps.length - 1 && (
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
)}
</div>
);
})}
</div>
);
}
interface ApprovalProcessTabProps { interface ApprovalProcessTabProps {
processes: any[]; processes: any[];
filteredInstances: any[]; filteredInstances: any[];
@@ -218,14 +146,14 @@ export function ApprovalProcessTab({
) : ( ) : (
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{filteredInstances.map((instance) => { {filteredInstances.map((instance) => {
const Icon = getApprovalTypeIcon(instance.type); const Icon = getApprovalTypeIcon(getInstanceType(instance));
const { percentage } = getProgressInfo(instance); const { percentage } = getProgressInfo(instance);
return ( return (
<Card key={instance.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-border)] transition-colors"> <Card key={instance.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] hover:border-[var(--dg-border)] transition-colors">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(instance.type) }}> <div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<div> <div>
@@ -235,8 +163,8 @@ export function ApprovalProcessTab({
</div> </div>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]"> <div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(instance.type) }}> <Badge variant="outline" className="text-xs border-[var(--dg-border)]" style={{ color: getApprovalTypeColor(getInstanceType(instance)) }}>
{getApprovalTypeLabel(instance.type)} {getApprovalTypeLabel(getInstanceType(instance))}
</Badge> </Badge>
</span> </span>
<span>申请人:{instance.applicant_name || instance.applicant || '--'}</span> <span>申请人:{instance.applicant_name || instance.applicant || '--'}</span>
@@ -0,0 +1,128 @@
// 资源申请模块共享辅助函数(从 ApprovalFlow.tsx 提取) // Logan.Li
import { cn } from '@/lib/utils';
import {
CheckCircle2,
XCircle,
Loader2,
Server,
GitCommit,
FileText,
} from 'lucide-react';
// 解析审批步骤数组(兼容 JSON 字符串) // Logan.Li
export function parseSteps(steps: any) {
if (Array.isArray(steps)) return steps;
if (typeof steps === 'string') {
try {
const parsed = JSON.parse(steps);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
// 解析模板的 form_schema,返回字段定义数组 // Logan.Li
export function parseFields(formSchema: any): any[] {
if (!formSchema) return [];
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
if (!schema) return [];
return Array.isArray(schema.fields) ? schema.fields : [];
}
// 在详情弹窗中渲染表单数据为键值对展示 // Logan.Li
export function renderFormData(formDataStr: string, template?: any) {
if (!formDataStr) return null;
let data: Record<string, any>;
try {
data = typeof formDataStr === 'string' ? JSON.parse(formDataStr) : formDataStr;
} catch {
return null;
}
if (!data || typeof data !== 'object') return null;
const fields = parseFields(template?.form_schema);
const labelMap = new Map<string, string>();
fields.forEach((f) => labelMap.set(f.key, f.label));
const entries = Object.entries(data).filter(([, v]) => v !== null && v !== undefined && v !== '');
if (entries.length === 0) return null;
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{entries.map(([key, value]) => (
<div key={key} className="min-w-0">
<p className="text-xs text-[var(--dg-text-muted)] mb-0.5">{labelMap.get(key) || key}</p>
<p className="text-sm text-[var(--dg-text-primary)] break-words">{String(value)}</p>
</div>
))}
</div>
);
}
// 审批类型选项 // Logan.Li
export const APPROVAL_TYPE_OPTIONS = [
{ value: 'server_apply', label: '云服务器申请', icon: Server, color: 'var(--dg-cyan)' },
{ value: 'app_deploy', label: '应用部署申请', icon: GitCommit, color: 'var(--dg-blue)' },
];
export const getApprovalTypeLabel = (value: string): string =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.label || value || '通用审批';
export const getApprovalTypeColor = (value: string): string =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
// 根据审批类型返回对应图标组件 // Logan.Li
export const getApprovalTypeIcon = (value: string) =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
// 获取审批实例的类型(优先从关联模板取) // Logan.Li
export const getInstanceType = (instance: any): string =>
instance?.template?.type || instance?.type || '';
// 计算审批进度信息 // Logan.Li
export function getProgressInfo(instance: any) {
const steps = parseSteps(instance.steps);
const currentIndex = instance.current_step ?? 0;
const total = steps.length;
const completed = steps.filter((s: any) => s.status === 'approved' || s.status === 'completed').length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { steps, currentIndex, total, completed, percentage };
}
// 渲染审批进度(用于详情弹窗) // Logan.Li
export function renderStepProgress(instance: any) {
const { steps, currentIndex } = getProgressInfo(instance);
if (steps.length === 0) return null;
return (
<div className="flex items-center gap-1 overflow-x-auto">
{steps.map((step: any, index: number) => {
const isCompleted = step.status === 'approved' || step.status === 'completed';
const isCurrent = index === currentIndex;
const isRejected = step.status === 'rejected';
return (
<div key={step.id || index} className="flex items-center">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs whitespace-nowrap',
isCompleted && 'bg-emerald-500/10 text-emerald-400',
isCurrent && !isRejected && 'bg-amber-500/10 text-amber-400 border border-amber-500/30',
isRejected && 'bg-red-500/10 text-red-400',
!isCompleted && !isCurrent && !isRejected && 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'
)}
>
{isCompleted && <CheckCircle2 className="h-3 w-3" />}
{isRejected && <XCircle className="h-3 w-3" />}
{isCurrent && !isRejected && <Loader2 className="h-3 w-3 animate-spin" />}
<span>{step.name || step.approver_name || `步骤 ${index + 1}`}</span>
</div>
{index < steps.length - 1 && (
<div className={cn('w-4 h-px', isCompleted ? 'bg-emerald-500/30' : 'bg-[var(--dg-border)]')} />
)}
</div>
);
})}
</div>
);
}
@@ -1,422 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Server,
Plus,
Search,
Grid3X3,
List,
MapPin,
Cpu,
MemoryStick,
HardDrive,
Eye,
Edit,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types';
export function CabinetView() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [servers, setServers] = useState<ServerType[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServer, setSelectedServer] = useState<ServerType | null>(null);
// 拉取服务器列表 // Logan.Li
const fetchServers = async () => {
setLoading(true);
try {
const resp = await serverApi.getServers({ page: 1, page_size: 200 });
setServers((resp?.list ?? []) as ServerType[]);
} catch {
setServers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchServers();
}, []);
const filteredServers = servers.filter((srv) => {
const matchesStatus = statusFilter === 'all' || srv.status === statusFilter;
const matchesSearch =
srv.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
srv.ip?.toLowerCase().includes(searchQuery.toLowerCase());
return matchesStatus && matchesSearch;
});
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
running: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
stopped: 'border-red-500/30 text-red-400 bg-red-500/10',
maintenance: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
offline: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
};
return colors[status] ?? colors.offline;
};
const getStatusText = (status: string) => {
const map: Record<string, string> = {
running: '运行中',
stopped: '已停止',
maintenance: '维护中',
offline: '离线',
};
return map[status] ?? status;
};
const getUsageColor = (usage: number) =>
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : 'bg-emerald-400';
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">机房机柜视图</h1>
<p className="text-sm text-slate-500 mt-1">数据中心服务器资产概览</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加设备
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">服务器总数</p>
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{servers.filter((s) => s.status === 'running').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Grid3X3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">维护中</p>
<p className="text-2xl font-bold text-amber-400">
{servers.filter((s) => s.status === 'maintenance').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Cpu className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">离线</p>
<p className="text-2xl font-bold text-slate-400">
{servers.filter((s) => s.status === 'offline' || s.status === 'stopped').length}
</p>
</div>
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
<HardDrive className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters & View Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索主机名或 IP..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('grid')}
>
<Grid3X3 className="h-4 w-4 mr-1" />
网格视图
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4 mr-1" />
列表视图
</Button>
</div>
</div>
{/* View Content */}
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filteredServers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-slate-500">
<MapPin className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">暂无服务器数据</p>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredServers.map((srv) => (
<Card
key={srv.id}
className="bg-slate-900 border-slate-800 cursor-pointer hover:border-slate-700 transition-all"
onClick={() => setSelectedServer(srv)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-500/10">
<Server className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</p>
<p className="text-xs text-slate-500">{srv.ip || '--'}</p>
</div>
</div>
<Badge variant="outline" className={getStatusColor(srv.status)}>
{getStatusText(srv.status)}
</Badge>
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" /> CPU
</span>
<span>{Math.round(srv.cpuUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.cpuUsage ?? 0)}`}
style={{ width: `${srv.cpuUsage ?? 0}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<MemoryStick className="h-3 w-3" /> 内存
</span>
<span>{Math.round(srv.memoryUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.memoryUsage ?? 0)}`}
style={{ width: `${srv.memoryUsage ?? 0}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" /> 磁盘
</span>
<span>{Math.round(srv.diskUsage ?? 0)}%</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${getUsageColor(srv.diskUsage ?? 0)}`}
style={{ width: `${srv.diskUsage ?? 0}%` }}
/>
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1">
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{srv.cpu ?? 0} 核
</Badge>
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{srv.memory ?? 0} GB
</Badge>
{srv.region && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{srv.region}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">主机名</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">IP地址</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">系统</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">区域</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">配置</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-400">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{filteredServers.map((srv) => (
<tr key={srv.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{srv.hostname || '--'}</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.ip || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.os || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-300">{srv.region || '--'}</td>
<td className="px-4 py-3 text-sm text-slate-400">
{srv.cpu ?? 0}核 / {srv.memory ?? 0}GB
</td>
<td className="px-4 py-3">
<Badge variant="outline" className={getStatusColor(srv.status)}>
{getStatusText(srv.status)}
</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedServer(srv)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Server Detail Dialog */}
<Dialog open={!!selectedServer} onOpenChange={() => setSelectedServer(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Server className="h-5 w-5 text-blue-400" />
{selectedServer?.hostname}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedServer?.ip} · {selectedServer?.os}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">IP 地址</p>
<p className="text-sm text-slate-200">{selectedServer?.ip || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">操作系统</p>
<p className="text-sm text-slate-200">{selectedServer?.os || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">配置</p>
<p className="text-sm text-slate-200">
{selectedServer?.cpu ?? 0} 核 / {selectedServer?.memory ?? 0} GB
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<Badge variant="outline" className={selectedServer ? getStatusColor(selectedServer.status) : ''}>
{selectedServer ? getStatusText(selectedServer.status) : ''}
</Badge>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Server className="h-4 w-4 mr-2" />
查看详情
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,394 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
Search,
Trash2,
Pencil,
Layers,
GripVertical,
X,
Loader2,
} from 'lucide-react';
import { resourceModelApi } from '@/services/api';
interface AttrDef {
name: string;
code: string;
type: string;
required: boolean;
unique?: boolean;
}
const attrTypes = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'bool', label: '布尔' },
{ value: 'date', label: '日期' },
{ value: 'enum', label: '枚举' },
];
const typeLabels: Record<string, string> = {
normal: '普通模型',
parent: '父模型',
};
export function ResourceModelManager() {
const [models, setModels] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [submitting, setSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState<any>(null);
const [formData, setFormData] = useState({
name: '', code: '', description: '', type: 'normal', icon: '',
});
const [attrs, setAttrs] = useState<AttrDef[]>([]);
const loadData = useCallback(async () => {
setLoading(true);
try {
const res = await resourceModelApi.list();
const items = (res as any)?.list || [];
setModels(items);
} catch {
setModels([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const filtered = models.filter(m =>
m.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
m.code?.toLowerCase().includes(searchQuery.toLowerCase())
);
const parseAttrs = (attrsStr: string): AttrDef[] => {
if (!attrsStr) return [];
try {
const parsed = JSON.parse(attrsStr);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const openCreate = () => {
setEditingItem(null);
setFormData({ name: '', code: '', description: '', type: 'normal', icon: '' });
setAttrs([]);
setDialogOpen(true);
};
const openEdit = (item: any) => {
setEditingItem(item);
setFormData({
name: item.name || '', code: item.code || '', description: item.description || '',
type: item.type || 'normal', icon: item.icon || '',
});
setAttrs(parseAttrs(item.attributes));
setDialogOpen(true);
};
const addAttr = () => {
setAttrs([...attrs, { name: '', code: '', type: 'string', required: false }]);
};
const updateAttr = (index: number, field: keyof AttrDef, value: any) => {
const next = [...attrs];
(next[index] as any)[field] = value;
setAttrs(next);
};
const removeAttr = (index: number) => {
setAttrs(attrs.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.code.trim()) return;
setSubmitting(true);
try {
const payload = { ...formData, attributes: attrs.filter(a => a.name.trim() && a.code.trim()) };
if (editingItem) {
await resourceModelApi.update(editingItem.id, payload);
} else {
await resourceModelApi.create(payload);
}
setDialogOpen(false);
loadData();
} catch {
} finally {
setSubmitting(false);
}
};
const [deleteId, setDeleteId] = useState<number | null>(null);
const handleDelete = (id: number) => setDeleteId(id);
const confirmDelete = async () => {
if (deleteId === null) return;
await resourceModelApi.delete(deleteId);
if (selectedModel?.id === deleteId) setSelectedModel(null);
setDeleteId(null);
loadData();
};
const getAttrCount = (item: any) => parseAttrs(item.attributes).length;
return (
<div className="p-6 space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between gap-4">
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索模型名称或编码..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700"
/>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="h-4 w-4" />
添加模型
</Button>
</div>
{/* List */}
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-20 text-slate-500">
<Layers className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>暂无资源模型</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filtered.map((m) => (
<div
key={m.id}
className="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors group cursor-pointer"
onClick={() => setSelectedModel(selectedModel?.id === m.id ? null : m)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<Layers className="h-5 w-5 text-cyan-400" />
</div>
<div>
<h3 className="font-medium text-slate-100">{m.name}</h3>
<span className="text-xs text-slate-500 font-mono">{m.code}</span>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={(e) => { e.stopPropagation(); openEdit(m); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={(e) => { e.stopPropagation(); handleDelete(m.id); }} className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-red-400">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{m.description && (
<p className="text-sm text-slate-400 mb-3 line-clamp-2">{m.description}</p>
)}
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-slate-400">
{typeLabels[m.type] || m.type}
</Badge>
<Badge variant="outline" className="text-slate-400">
{getAttrCount(m)} 个属性
</Badge>
</div>
{/* Expanded attributes */}
{selectedModel?.id === m.id && (
<div className="mt-3 pt-3 border-t border-slate-700/50 space-y-1.5">
{parseAttrs(m.attributes).map((attr, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-slate-300">{attr.name}</span>
<div className="flex items-center gap-2">
<span className="text-slate-500 font-mono">{attr.code}</span>
<span className="text-slate-600">{attr.type}</span>
{attr.required && <span className="text-red-400">*</span>}
</div>
</div>
))}
{getAttrCount(m) === 0 && (
<p className="text-xs text-slate-600">暂无属性定义</p>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingItem ? '编辑资源模型' : '添加资源模型'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 max-h-[60vh] overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>模型名称 *</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="如:服务器"
className="bg-slate-800/50 border-slate-700"
/>
</div>
<div className="space-y-1.5">
<Label>编码 *</Label>
<Input
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
placeholder="如:server"
className="bg-slate-800/50 border-slate-700 font-mono"
disabled={!!editingItem}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>模型类型</Label>
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v })}>
<SelectTrigger className="bg-slate-800/50 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">普通模型</SelectItem>
<SelectItem value="parent">父模型</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>描述</Label>
<Input
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="模型描述..."
className="bg-slate-800/50 border-slate-700"
/>
</div>
</div>
{/* Attributes editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>属性定义</Label>
<Button type="button" variant="ghost" size="sm" onClick={addAttr} className="h-7 text-xs gap-1">
<Plus className="h-3 w-3" /> 添加属性
</Button>
</div>
<div className="space-y-2">
{attrs.map((attr, i) => (
<div key={i} className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-slate-600 shrink-0" />
<Input
placeholder="属性名"
value={attr.name}
onChange={(e) => updateAttr(i, 'name', e.target.value)}
className="h-8 bg-slate-800/50 border-slate-700 flex-1"
/>
<Input
placeholder="编码"
value={attr.code}
onChange={(e) => updateAttr(i, 'code', e.target.value)}
className="h-8 bg-slate-800/50 border-slate-700 flex-1 font-mono text-sm"
/>
<Select value={attr.type} onValueChange={(v) => updateAttr(i, 'type', v)}>
<SelectTrigger className="h-8 w-28 bg-slate-800/50 border-slate-700">
<SelectValue />
</SelectTrigger>
<SelectContent>
{attrTypes.map(t => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 shrink-0 ${attr.required ? 'text-red-400' : 'text-slate-500'}`}
onClick={() => updateAttr(i, 'required', !attr.required)}
title={attr.required ? '必填' : '选填'}
>
*
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-slate-500 hover:text-red-400"
onClick={() => removeAttr(i)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
))}
{attrs.length === 0 && (
<p className="text-sm text-slate-600 py-2">暂无属性,点击"添加属性"开始定义</p>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting || !formData.name.trim() || !formData.code.trim()}>
{submitting && <Loader2 className="h-4 w-4 mr-1 animate-spin" />}
{editingItem ? '保存' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={deleteId !== null} onOpenChange={(v) => !v && setDeleteId(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-sm text-slate-400">确定删除此资源模型?此操作不可撤销。</p>
<DialogFooter>
<Button variant="ghost" onClick={() => setDeleteId(null)}>取消</Button>
<Button variant="destructive" onClick={confirmDelete}>删除</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}